diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 29d5a95ea01..085aa9c2b01 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,6 +8,7 @@ "PYTHONASYNCIODEBUG": "1" }, "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, // Port 5683 udp is used by Shelly integration diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml new file mode 100644 index 00000000000..5c286613068 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -0,0 +1,53 @@ +name: Task +description: For staff only - Create a task +type: Task +body: + - type: markdown + attributes: + value: | + ## ⚠️ RESTRICTED ACCESS + + **This form is restricted to Open Home Foundation staff, authorized contributors, and integration code owners only.** + + If you are a community member wanting to contribute, please: + - For bug reports: Use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml) + - For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions) + + --- + + ### For authorized contributors + + Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked. + - type: textarea + id: description + attributes: + label: Description + description: | + Provide a clear and detailed description of the task that needs to be accomplished. + + Be specific about what needs to be done, why it's important, and any constraints or requirements. + placeholder: | + Describe the task, including: + - What needs to be done + - Why this task is needed + - Expected outcome + - Any constraints or requirements + validations: + required: true + - type: textarea + id: additional_context + attributes: + label: Additional context + description: | + Any additional information, links, research, or context that would be helpful. + + Include links to related issues, research, prototypes, roadmap opportunities etc. + placeholder: | + - Roadmap opportunity: [link] + - Epic: [link] + - Feature request: [link] + - Technical design documents: [link] + - Prototype/mockup: [link] + - Dependencies: [links] + validations: + required: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 06499d62b9e..7eba0203f7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,100 +1,1167 @@ -# Instructions for GitHub Copilot +# GitHub Copilot & Claude Code Instructions -This repository holds the core of Home Assistant, a Python 3 based home -automation application. +This repository contains the core of Home Assistant, a Python 3 based home automation application. -- Python code must be compatible with Python 3.13 -- Use the newest Python language features if possible: +## 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 + +### 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 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) + +## Python Requirements + +- **Compatibility**: Python 3.13+ +- **Language Features**: Use the newest features when possible: - Pattern matching - Type hints - - f-strings for string formatting over `%` or `.format()` + - f-strings (preferred over `%` or `.format()`) - Dataclasses - Walrus operator -- Code quality tools: - - Formatting: Ruff - - Linting: PyLint and Ruff - - Type checking: MyPy - - Testing: pytest with plain functions and fixtures -- Inline code documentation: - - File headers should be short and concise: - ```python - """Integration for Peblar EV chargers.""" - ``` - - Every method and function needs a docstring: - ```python - async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: - """Set up Peblar from a config entry.""" - ... - ``` -- All code and comments and other text are written in American English -- Follow existing code style patterns as much as possible -- Core locations: - - Shared constants: `homeassistant/const.py`, use them instead of hardcoding - strings or creating duplicate integration constants. - - Integration files: - - Constants: `homeassistant/components/{domain}/const.py` - - Models: `homeassistant/components/{domain}/models.py` - - Coordinator: `homeassistant/components/{domain}/coordinator.py` - - Config flow: `homeassistant/components/{domain}/config_flow.py` - - Platform code: `homeassistant/components/{domain}/{platform}.py` + +### 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 +- **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 + +## 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"] + } + ``` + +### 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 -- Async patterns: +- **Best Practices**: - Avoid sleeping in loops - - Avoid awaiting in loops, gather instead + - Avoid awaiting in loops - use `gather` instead - No blocking calls -- Polling: - - Follow update coordinator pattern, when possible - - Polling interval may not be configurable by the user - - For local network polling, the minimum interval is 5 seconds - - For cloud polling, the minimum interval is 60 seconds -- Error handling: - - Use specific exceptions from `homeassistant.exceptions` - - Setup failures: - - Temporary: Raise `ConfigEntryNotReady` - - Permanent: Use `ConfigEntryError` -- Logging: - - Message format: - - No periods at end - - No integration names or domains (added automatically) - - No sensitive data (keys, tokens, passwords), even when those are incorrect. - - Be very restrictive on the use of logging info messages, use debug for - anything which is not targeting the user. - - Use lazy logging (no f-strings): + - Group executor jobs when possible - switching between event loop and executor is expensive + +### 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) + +### 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 + +### 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) + ``` + +### 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 - _LOGGER.debug("This is a log message with %s", variable) + 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") ``` -- Entities: - - Ensure unique IDs for state persistence: - - Unique IDs should not contain values that are subject to user or network change. - - An ID needs to be unique per platform, not per integration. - - The ID does not have to contain the integration domain or platform. - - Acceptable examples: - - Serial number of a device - - MAC address of a device formatted using `homeassistant.helpers.device_registry.format_mac` - Do not obtain the MAC address through arp cache of local network access, - only use the MAC address provided by discovery or the device itself. - - Unique identifier that is physically printed on the device or burned into an EEPROM - - Not acceptable examples: - - IP Address - - Device name - - Hostname - - URL - - Email address - - Username - - For entities that are setup by a config entry, the config entry ID - can be used as a last resort if no other Unique ID is available. - For example: `f"{entry.entry_id}-battery"` - - If the state value is unknown, use `None` - - Do not use the `unavailable` string as a state value, - implement the `available()` property method instead - - Do not use the `unknown` string as a state value, use `None` instead -- Extra entity state attributes: - - The keys of all state attributes should always be present - - If the value is unknown, use `None` - - Provide descriptive state attributes -- Testing: - - Test location: `tests/components/{domain}/` + - 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 + ``` + +## 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 + +## Diagnostics and Repairs + +### Integration Diagnostics +- **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 + +### Repair Issues +- **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 + +### 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 external dependencies - - Use snapshots for complex data + - 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 + +## Development Commands + +### Code Quality & Linting +- **Run all linters on all files**: `pre-commit run --all-files` +- **Run linters on staged files only**: `pre-commit 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 +- **Integration-specific tests** (recommended): + ```bash + pytest ./tests/components/ \ + --cov=homeassistant.components. \ + --cov-report term-missing \ + --durations-min=1 \ + --durations=0 \ + --numprocesses=auto + ``` +- **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 + ``` + +### 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 +``` + +### 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 + +### 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 + +## 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 + ) +``` + +### Entity Performance Optimization +```python +# Use __slots__ for memory efficiency +class MySensor(SensorEntity): + __slots__ = ("_attr_native_value", "_attr_available") + + @property + def should_poll(self) -> bool: + """Disable polling when using coordinator.""" + return False # ✅ Let coordinator handle updates +``` + +## Testing Patterns + +### 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.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True) +@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 +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_api: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry +``` + +## Debugging & Troubleshooting + +### Common Issues & Solutions +- **Integration won't load**: Check `manifest.json` syntax and required fields +- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation +- **Config flow errors**: Check `strings.json` entries and error handling +- **Discovery not working**: Verify manifest discovery configuration and callbacks +- **Tests failing**: Check mock setup and async context + +### Debug Logging Setup +```python +# Enable debug logging in tests +caplog.set_level(logging.DEBUG, logger="my_integration") + +# In integration code - use proper logging +_LOGGER = logging.getLogger(__name__) +_LOGGER.debug("Processing data: %s", data) # Use lazy logging +``` + +### Validation Commands +```bash +# Check specific integration +python -m script.hassfest --integration-path homeassistant/components/my_integration + +# Validate quality scale +# Check quality_scale.yaml against current rules + +# Run integration tests with coverage +pytest ./tests/components/my_integration \ + --cov=homeassistant.components.my_integration \ + --cov-report term-missing +``` \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a394d7dcbba..f9bfa9b406d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,6 @@ updates: interval: daily time: "06:00" open-pull-requests-limit: 10 + labels: + - dependency + - github_actions diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5ac2e47789b..c848ac793af 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -27,7 +27,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: fetch-depth: 0 @@ -90,7 +90,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: translations @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -242,7 +242,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set build additional args run: | @@ -256,7 +256,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -279,7 +279,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -321,23 +321,23 @@ jobs: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.1 + uses: sigstore/cosign-installer@v3.9.2 with: cosign-release: "v2.2.3" - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -454,7 +454,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: translations @@ -499,10 +499,10 @@ jobs: HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19cc8bd3af7..2116834364e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,10 +37,10 @@ on: type: boolean env: - CACHE_VERSION: 3 + CACHE_VERSION: 5 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2025.7" + HA_SHORT_VERSION: "2025.9" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version @@ -94,7 +94,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: | @@ -246,7 +246,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -255,7 +255,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: venv key: >- @@ -271,7 +271,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -292,7 +292,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 id: python @@ -301,7 +301,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -310,7 +310,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -332,7 +332,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 id: python @@ -341,7 +341,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -350,7 +350,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -372,7 +372,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 id: python @@ -381,7 +381,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -390,7 +390,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -462,7 +462,7 @@ jobs: - script/hassfest/docker/Dockerfile steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Register hadolint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/hadolint.json" @@ -481,7 +481,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -497,7 +497,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: venv key: >- @@ -505,7 +505,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -584,7 +584,7 @@ jobs: sudo apt-get -y install \ libturbojpeg - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -593,7 +593,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -617,7 +617,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -626,7 +626,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -651,9 +651,9 @@ jobs: && github.event_name == 'pull_request' steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Dependency review - uses: actions/dependency-review-action@v4.7.1 + uses: actions/dependency-review-action@v4.7.2 with: license-check: false # We use our own license audit checks @@ -674,7 +674,7 @@ jobs: python-version: ${{ fromJson(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -683,7 +683,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -717,7 +717,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -726,7 +726,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -764,7 +764,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -773,7 +773,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -809,7 +809,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -825,7 +825,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -833,7 +833,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: .mypy_cache key: >- @@ -886,7 +886,7 @@ jobs: libturbojpeg \ libgammu-dev - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.6.0 @@ -895,7 +895,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -947,7 +947,7 @@ jobs: libgammu-dev \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -956,7 +956,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -970,7 +970,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: pytest_buckets - name: Compile English translations @@ -1080,7 +1080,7 @@ jobs: libmariadb-dev-compat \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -1089,7 +1089,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1222,7 +1222,7 @@ jobs: sudo apt-get -y install \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -1231,7 +1231,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1334,9 +1334,9 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1381,7 +1381,7 @@ jobs: libgammu-dev \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.6.0 @@ -1390,7 +1390,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1484,9 +1484,9 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1511,7 +1511,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 583cfdd211c..8673c5f4b87 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,14 +21,14 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.0 + uses: github/codeql-action/init@v3.29.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.0 + uses: github/codeql-action/analyze@v3.29.9 with: category: "/language:python" diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index b01a0d68352..5f9522e0593 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,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@v1.1.0 + uses: actions/ai-inference@v2.0.0 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 264b8ab9854..bcad5726968 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v2.0.0 with: model: openai/gpt-4o-mini system-prompt: | diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml new file mode 100644 index 00000000000..36d9688f50a --- /dev/null +++ b/.github/workflows/restrict-task-creation.yml @@ -0,0 +1,84 @@ +name: Restrict task creation + +# yamllint disable-line rule:truthy +on: + issues: + types: [opened] + +jobs: + check-authorization: + runs-on: ubuntu-latest + # Only run if this is a Task issue type (from the issue form) + if: github.event.issue.type.name == 'Task' + steps: + - name: Check if user is authorized + uses: actions/github-script@v7 + with: + script: | + const issueAuthor = context.payload.issue.user.login; + + // First check if user is an organization member + try { + await github.rest.orgs.checkMembershipForUser({ + org: 'home-assistant', + username: issueAuthor + }); + console.log(`✅ ${issueAuthor} is an organization member`); + return; // Authorized, no need to check further + } catch (error) { + console.log(`ℹ️ ${issueAuthor} is not an organization member, checking codeowners...`); + } + + // If not an org member, check if they're a codeowner + try { + // Fetch CODEOWNERS file from the repository + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: 'CODEOWNERS', + ref: 'dev' + }); + + // Decode the content (it's base64 encoded) + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8'); + + // Check if the issue author is mentioned in CODEOWNERS + // GitHub usernames in CODEOWNERS are prefixed with @ + if (codeownersContent.includes(`@${issueAuthor}`)) { + console.log(`✅ ${issueAuthor} is a integration code owner`); + return; // Authorized + } + } catch (error) { + console.error('Error checking CODEOWNERS:', error); + } + + // If we reach here, user is not authorized + console.log(`❌ ${issueAuthor} is not authorized to create Task issues`); + + // Close the issue with a comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` + + `Task issues are restricted to Open Home Foundation staff, authorized contributors, and integration code owners.\n\n` + + `If you would like to:\n` + + `- Report a bug: Please use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)\n` + + `- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` + + `If you believe you should have access to create Task issues, please contact the maintainers.` + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed' + }); + + // Add a label to indicate this was auto-closed + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['auto-closed'] + }); diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 8a668d548d3..004b552cab3 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ea02b249dc9..883cc688cf5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -32,7 +32,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python @@ -135,20 +135,20 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download env_file - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_diff @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.03.0 + uses: home-assistant/wheels@2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -184,25 +184,25 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Download env_file - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_all_wheels @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.03.0 + uses: home-assistant/wheels@2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 diff --git a/.gitignore b/.gitignore index 5aa51c9d762..9bcf440a2f1 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,8 @@ tmp_cache .ropeproject # Will be created from script/split_tests.py -pytest_buckets.txt \ No newline at end of file +pytest_buckets.txt + +# AI tooling +.claude + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30351a9381e..d87187b55be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.0 + rev: v0.12.1 hooks: - id: ruff-check args: @@ -18,7 +18,7 @@ repos: exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] diff --git a/.strict-typing b/.strict-typing index a76ba3885bc..b3e41747239 100644 --- a/.strict-typing +++ b/.strict-typing @@ -53,6 +53,7 @@ homeassistant.components.air_quality.* homeassistant.components.airgradient.* homeassistant.components.airly.* homeassistant.components.airnow.* +homeassistant.components.airos.* homeassistant.components.airq.* homeassistant.components.airthings.* homeassistant.components.airthings_ble.* @@ -309,7 +310,6 @@ homeassistant.components.letpot.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* -homeassistant.components.linear_garage_door.* homeassistant.components.linkplay.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* @@ -377,10 +377,12 @@ homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* +homeassistant.components.open_router.* homeassistant.components.openai_conversation.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* homeassistant.components.openuv.* +homeassistant.components.opower.* homeassistant.components.oralb.* homeassistant.components.otbr.* homeassistant.components.overkiz.* @@ -464,6 +466,7 @@ homeassistant.components.simplisafe.* homeassistant.components.siren.* homeassistant.components.skybell.* homeassistant.components.slack.* +homeassistant.components.sleep_as_android.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* homeassistant.components.smlight.* @@ -499,6 +502,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tailwind.* homeassistant.components.tami4.* +homeassistant.components.tankerkoenig.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.technove.* @@ -534,6 +538,7 @@ homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* homeassistant.components.uptime.* +homeassistant.components.uptime_kuma.* homeassistant.components.uptimerobot.* homeassistant.components.usb.* homeassistant.components.uvc.* @@ -543,6 +548,7 @@ homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* +homeassistant.components.volvo.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000000..02dd134122e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index 419347d08a7..7da06479b92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -67,6 +67,8 @@ build.json @home-assistant/supervisor /tests/components/airly/ @bieniu /homeassistant/components/airnow/ @asymworks /tests/components/airnow/ @asymworks +/homeassistant/components/airos/ @CoMPaTech +/tests/components/airos/ @CoMPaTech /homeassistant/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen @LaStrada @@ -154,8 +156,8 @@ build.json @home-assistant/supervisor /tests/components/assist_pipeline/ @balloob @synesthesiam /homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam /tests/components/assist_satellite/ @home-assistant/core @synesthesiam -/homeassistant/components/asuswrt/ @kennedyshead @ollo69 -/tests/components/asuswrt/ @kennedyshead @ollo69 +/homeassistant/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi +/tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi /homeassistant/components/atag/ @MatsNL /tests/components/atag/ @MatsNL /homeassistant/components/aten_pe/ @mtdcr @@ -420,6 +422,8 @@ build.json @home-assistant/supervisor /homeassistant/components/emby/ @mezz64 /homeassistant/components/emoncms/ @borpin @alexandrecuer /tests/components/emoncms/ @borpin @alexandrecuer +/homeassistant/components/emoncms_history/ @alexandrecuer +/tests/components/emoncms_history/ @alexandrecuer /homeassistant/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco /homeassistant/components/emulated_hue/ @bdraco @Tho85 @@ -436,8 +440,8 @@ build.json @home-assistant/supervisor /tests/components/enigma2/ @autinerd /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer -/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac -/tests/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac +/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac +/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie @@ -452,8 +456,8 @@ build.json @home-assistant/supervisor /tests/components/eq3btsmart/ @eulemitkeule @dbuezas /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila -/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco -/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco +/homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco +/tests/components/esphome/ @jesserockz @kbx81 @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/event/ @home-assistant/core @@ -684,8 +688,8 @@ build.json @home-assistant/supervisor /tests/components/husqvarna_automower/ @Thomas55555 /homeassistant/components/husqvarna_automower_ble/ @alistair23 /tests/components/husqvarna_automower_ble/ @alistair23 -/homeassistant/components/huum/ @frwickst -/tests/components/huum/ @frwickst +/homeassistant/components/huum/ @frwickst @vincentwolsink +/tests/components/huum/ @frwickst @vincentwolsink /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan @@ -860,8 +864,6 @@ build.json @home-assistant/supervisor /tests/components/lifx/ @Djelibeybi /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core -/homeassistant/components/linear_garage_door/ @IceBotYT -/tests/components/linear_garage_door/ @IceBotYT /homeassistant/components/linkplay/ @Velleman /tests/components/linkplay/ @Velleman /homeassistant/components/linux_battery/ @fabaff @@ -1102,6 +1104,8 @@ build.json @home-assistant/supervisor /tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck +/homeassistant/components/open_router/ @joostlek +/tests/components/open_router/ @joostlek /homeassistant/components/openai_conversation/ @balloob /tests/components/openai_conversation/ @balloob /homeassistant/components/openerz/ @misialq @@ -1169,8 +1173,8 @@ build.json @home-assistant/supervisor /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan /tests/components/plaato/ @JohNan -/homeassistant/components/playstation_network/ @jackjpowell -/tests/components/playstation_network/ @jackjpowell +/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r +/tests/components/playstation_network/ @jackjpowell @tr4nt0r /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren /homeassistant/components/plugwise/ @CoMPaTech @bouwew @@ -1413,6 +1417,8 @@ build.json @home-assistant/supervisor /tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @tkdrob @fletcherau /tests/components/slack/ @tkdrob @fletcherau +/homeassistant/components/sleep_as_android/ @tr4nt0r +/tests/components/sleep_as_android/ @tr4nt0r /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 @@ -1553,6 +1559,8 @@ build.json @home-assistant/supervisor /tests/components/technove/ @Moustachauve /homeassistant/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj +/homeassistant/components/telegram_bot/ @hanwg +/tests/components/telegram_bot/ @hanwg /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/template/ @Petro31 @home-assistant/core @@ -1593,6 +1601,8 @@ build.json @home-assistant/supervisor /tests/components/todo/ @home-assistant/core /homeassistant/components/todoist/ @boralyl /tests/components/todoist/ @boralyl +/homeassistant/components/togrill/ @elupus +/tests/components/togrill/ @elupus /homeassistant/components/tolo/ @MatthiasLohr /tests/components/tolo/ @MatthiasLohr /homeassistant/components/tomorrowio/ @raman325 @lymanepp @@ -1607,8 +1617,6 @@ build.json @home-assistant/supervisor /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus -/homeassistant/components/traccar_server/ @ludeeus -/tests/components/traccar_server/ @ludeeus /homeassistant/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu @@ -1656,6 +1664,8 @@ build.json @home-assistant/supervisor /tests/components/upnp/ @StevenLooman /homeassistant/components/uptime/ @frenck /tests/components/uptime/ @frenck +/homeassistant/components/uptime_kuma/ @tr4nt0r +/tests/components/uptime_kuma/ @tr4nt0r /homeassistant/components/uptimerobot/ @ludeeus @chemelli74 /tests/components/uptimerobot/ @ludeeus @chemelli74 /homeassistant/components/usb/ @bdraco @@ -1700,6 +1710,8 @@ build.json @home-assistant/supervisor /tests/components/voip/ @balloob @synesthesiam @jaminh /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund +/homeassistant/components/volvo/ @thomasddn +/tests/components/volvo/ @thomasddn /homeassistant/components/volvooncall/ @molobrakos /tests/components/volvooncall/ @molobrakos /homeassistant/components/vulcan/ @Antoni-Czaplicki @@ -1754,8 +1766,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/withings/ @joostlek /tests/components/withings/ @joostlek -/homeassistant/components/wiz/ @sbidy -/tests/components/wiz/ @sbidy +/homeassistant/components/wiz/ @sbidy @arturpragacz +/tests/components/wiz/ @sbidy @arturpragacz /homeassistant/components/wled/ @frenck /tests/components/wled/ @frenck /homeassistant/components/wmspro/ @mback2k diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f8a79ab901..e7d8488048e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,5 +14,8 @@ Still interested? Then you should take a peek at the [developer documentation](h ## Feature suggestions -If you want to suggest a new feature for Home Assistant (e.g., new integrations), please open a thread in our [Community Forum: Feature Requests](https://community.home-assistant.io/c/feature-requests). -We use [GitHub for tracking issues](https://github.com/home-assistant/core/issues), not for tracking feature requests. +If you want to suggest a new feature for Home Assistant (e.g. new integrations), please [start a discussion](https://github.com/orgs/home-assistant/discussions) on GitHub. + +## Issue Tracker + +If you want to report an issue, please [create an issue](https://github.com/home-assistant/core/issues) on GitHub. diff --git a/Dockerfile b/Dockerfile index 549837ddef0..4a004c046e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.7.1 +RUN pip3 install uv==0.8.9 WORKDIR /usr/src diff --git a/Dockerfile.dev b/Dockerfile.dev index 5a3f1a2ae64..4c037799567 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,15 +1,7 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.13 +FROM mcr.microsoft.com/vscode/devcontainers/base:debian SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# Uninstall pre-installed formatting and linting tools -# They would conflict with our pinned versions -RUN \ - pipx uninstall pydocstyle \ - && pipx uninstall pycodestyle \ - && pipx uninstall mypy \ - && pipx uninstall pylint - RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ @@ -32,21 +24,18 @@ RUN \ libxml2 \ git \ cmake \ + autoconf \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Add go2rtc binary COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc -# Install uv -RUN pip3 install uv - WORKDIR /usr/src -# Setup hass-release -RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ - && uv pip install --system -e hass-release/ \ - && chown -R vscode /usr/src/hass-release/data +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +RUN uv python install 3.13.2 USER vscode ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" @@ -55,6 +44,10 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH" WORKDIR /tmp +# Setup hass-release +RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \ + && uv pip install -e ~/hass-release/ + # Install Python dependencies from requirements COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt @@ -65,4 +58,4 @@ RUN uv pip install -r requirements_test.txt WORKDIR /workspaces # Set the default shell to bash instead of sh -ENV SHELL /bin/bash +ENV SHELL=/bin/bash diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 1c2e8b0dfab..429aad09edb 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -120,6 +120,9 @@ class AuthStore: new_user = models.User(**kwargs) + while new_user.id in self._users: + new_user = models.User(**kwargs) + self._users[new_user.id] = new_user if credentials is None: diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 7dcccbb1a1e..f92ed38ad85 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -33,7 +33,10 @@ class AuthFlowContext(FlowContext, total=False): redirect_uri: str -AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]] +class AuthFlowResult(FlowResult[AuthFlowContext, tuple[str, str]], total=False): + """Typed result dict for auth flow.""" + + result: Credentials # Only present if type is CREATE_ENTRY @attr.s(slots=True) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 810c1f1e8d2..4e49d6cec7e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -75,8 +75,8 @@ from .core_config import async_process_ha_core_config from .exceptions import HomeAssistantError from .helpers import ( area_registry, - backup, category_registry, + condition, config_validation as cv, device_registry, entity, @@ -89,6 +89,7 @@ from .helpers import ( restore_state, template, translation, + trigger, ) from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager @@ -331,6 +332,9 @@ async def async_setup_hass( if not is_virtual_env(): await async_mount_local_lib_path(runtime_config.config_dir) + if hass.config.safe_mode: + _LOGGER.info("Starting in safe mode") + basic_setup_success = ( await async_from_config_dict(config_dict, hass) is not None ) @@ -383,8 +387,6 @@ async def async_setup_hass( {"recovery_mode": {}, "http": http_conf}, hass, ) - elif hass.config.safe_mode: - _LOGGER.info("Starting in safe mode") if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) @@ -452,6 +454,8 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: 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)), ) @@ -605,7 +609,7 @@ async def async_enable_logging( ) threading.excepthook = lambda args: logging.getLogger().exception( "Uncaught thread exception", - exc_info=( # type: ignore[arg-type] # noqa: LOG014 + exc_info=( # type: ignore[arg-type] args.exc_type, args.exc_value, args.exc_traceback, @@ -691,10 +695,10 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" - # Filter out the repeating and common config section [homeassistant] - domains = { - domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN - } + # The common config section [homeassistant] could be filtered here, + # but that is not necessary, since it corresponds to the core integration, + # that is always unconditionally loaded. + domains = {cv.domain_key(key) for key in config} # Add config entry and default domains if not hass.config.recovery_mode: @@ -722,34 +726,28 @@ async def _async_resolve_domains_and_preload( together with all their dependencies. """ domains_to_setup = _get_domains(hass, config) - platform_integrations = conf_util.extract_platform_integrations( - config, BASE_PLATFORMS - ) - # Ensure base platforms that have platform integrations are added to `domains`, - # so they can be setup first instead of discovering them later when a config - # entry setup task notices that it's needed and there is already a long line - # to use the import executor. + + # Also process all base platforms since we do not require the manifest + # to list them as dependencies. + # We want to later avoid lock contention when multiple integrations try to load + # their manifests at once. # + # Additionally process integrations that are defined under base platforms + # to speed things up. # For example if we have # sensor: # - platform: template # - # `template` has to be loaded to validate the config for sensor - # so we want to start loading `sensor` as soon as we know - # it will be needed. The more platforms under `sensor:`, the longer + # `template` has to be loaded to validate the config for sensor. + # The more platforms under `sensor:`, the longer # it will take to finish setup for `sensor` because each of these # platforms has to be imported before we can validate the config. # # Thankfully we are migrating away from the platform pattern # so this will be less of a problem in the future. - domains_to_setup.update(platform_integrations) - - # Additionally process base platforms since we do not require the manifest - # to list them as dependencies. - # We want to later avoid lock contention when multiple integrations try to load - # their manifests at once. - # Also process integrations that are defined under base platforms - # to speed things up. + platform_integrations = conf_util.extract_platform_integrations( + config, BASE_PLATFORMS + ) additional_domains_to_process = { *BASE_PLATFORMS, *chain.from_iterable(platform_integrations.values()), @@ -867,9 +865,9 @@ async def _async_set_up_integrations( domains = set(integrations) & all_domains _LOGGER.info( - "Domains to be set up: %s | %s", - domains, - all_domains - domains, + "Domains to be set up: %s\nDependencies: %s", + domains or "{}", + (all_domains - domains) or "{}", ) async_set_domains_to_be_loaded(hass, all_domains) @@ -878,10 +876,6 @@ async def _async_set_up_integrations( if "recorder" in all_domains: recorder.async_initialize_recorder(hass) - # Initialize backup - if "backup" in all_domains: - backup.async_initialize_backup(hass) - stages: list[tuple[str, set[str], int | None]] = [ *( (name, domain_group, timeout) @@ -914,12 +908,13 @@ async def _async_set_up_integrations( stage_all_domains = stage_domains | stage_dep_domains _LOGGER.info( - "Setting up stage %s: %s | %s\nDependencies: %s | %s", + "Setting up stage %s: %s; already set up: %s\n" + "Dependencies: %s; already set up: %s", name, stage_domains, - stage_domains_unfiltered - stage_domains, - stage_dep_domains, - stage_dep_domains_unfiltered - stage_dep_domains, + (stage_domains_unfiltered - stage_domains) or "{}", + stage_dep_domains or "{}", + (stage_dep_domains_unfiltered - stage_dep_domains) or "{}", ) if timeout is None: @@ -1059,5 +1054,5 @@ async def _async_setup_multi_components( _LOGGER.error( "Error setting up integration %s - received exception", domain, - exc_info=(type(result), result, result.__traceback__), # noqa: LOG014 + exc_info=(type(result), result, result.__traceback__), ) diff --git a/homeassistant/brands/frient.json b/homeassistant/brands/frient.json new file mode 100644 index 00000000000..e6b4374576f --- /dev/null +++ b/homeassistant/brands/frient.json @@ -0,0 +1,5 @@ +{ + "domain": "frient", + "name": "Frient", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/brands/third_reality.json b/homeassistant/brands/third_reality.json index 172b74c42fc..7a4304dad9f 100644 --- a/homeassistant/brands/third_reality.json +++ b/homeassistant/brands/third_reality.json @@ -1,5 +1,5 @@ { "domain": "third_reality", "name": "Third Reality", - "iot_standards": ["zigbee"] + "iot_standards": ["matter", "zigbee"] } diff --git a/homeassistant/brands/ubiquiti.json b/homeassistant/brands/ubiquiti.json index 8b64cffaa7e..bb345775a60 100644 --- a/homeassistant/brands/ubiquiti.json +++ b/homeassistant/brands/ubiquiti.json @@ -1,5 +1,5 @@ { "domain": "ubiquiti", "name": "Ubiquiti", - "integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"] + "integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"] } diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index a3aeab9deb9..2e7e977cf3d 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -336,7 +336,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( keys=[AOD_WEATHER, AOD_WIND_DIRECTION], name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 692e5d410ae..a16e11c05d7 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -1,11 +1,12 @@ """Integration to offer AI tasks to Home Assistant.""" import logging +from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR from homeassistant.core import ( HassJobType, HomeAssistant, @@ -14,12 +15,15 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.helpers import config_validation as cv, storage +from homeassistant.helpers import config_validation as cv, selector, storage from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( + ATTR_ATTACHMENTS, ATTR_INSTRUCTIONS, + ATTR_REQUIRED, + ATTR_STRUCTURE, ATTR_TASK_NAME, DATA_COMPONENT, DATA_PREFERENCES, @@ -47,6 +51,27 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +STRUCTURE_FIELD_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DESCRIPTION): str, + vol.Optional(ATTR_REQUIRED): bool, + vol.Required(CONF_SELECTOR): selector.validate_selector, + } +) + + +def _validate_structure_fields(value: dict[str, Any]) -> vol.Schema: + """Validate the structure fields as a voluptuous Schema.""" + if not isinstance(value, dict): + raise vol.Invalid("Structure must be a dictionary") + fields = {} + for k, v in value.items(): + field_class = vol.Required if v.get(ATTR_REQUIRED, False) else vol.Optional + fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector( + v[CONF_SELECTOR] + ) + return vol.Schema(fields, extra=vol.PREVENT_EXTRA) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" @@ -64,6 +89,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Required(ATTR_TASK_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_id, vol.Required(ATTR_INSTRUCTIONS): cv.string, + vol.Optional(ATTR_STRUCTURE): vol.All( + vol.Schema({str: STRUCTURE_FIELD_SCHEMA}), + _validate_structure_fields, + ), + vol.Optional(ATTR_ATTACHMENTS): vol.All( + cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})] + ), } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index 8b612e90560..09948e9b673 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -21,6 +21,9 @@ SERVICE_GENERATE_DATA = "generate_data" ATTR_INSTRUCTIONS: Final = "instructions" ATTR_TASK_NAME: Final = "task_name" +ATTR_STRUCTURE: Final = "structure" +ATTR_REQUIRED: Final = "required" +ATTR_ATTACHMENTS: Final = "attachments" DEFAULT_SYSTEM_PROMPT = ( "You are a Home Assistant expert and help users with their tasks." @@ -32,3 +35,6 @@ class AITaskEntityFeature(IntFlag): GENERATE_DATA = 1 """Generate data based on instructions.""" + + SUPPORT_ATTACHMENTS = 2 + """Support attachments with generate data.""" diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index cb6094cba4e..4c5cd186943 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -13,7 +13,7 @@ from homeassistant.components.conversation import ( ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import llm -from homeassistant.helpers.chat_session import async_get_chat_session +from homeassistant.helpers.chat_session import ChatSession from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity): @contextlib.asynccontextmanager async def _async_get_ai_task_chat_log( self, + session: ChatSession, task: GenDataTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( - async_get_chat_session(self.hass) as session, async_get_chat_log( self.hass, session, @@ -79,19 +79,22 @@ class AITaskEntity(RestoreEntity): user_llm_prompt=DEFAULT_SYSTEM_PROMPT, ) - chat_log.async_add_user_content(UserContent(task.instructions)) + chat_log.async_add_user_content( + UserContent(task.instructions, attachments=task.attachments) + ) yield chat_log @final async def internal_async_generate_data( self, + session: ChatSession, task: GenDataTask, ) -> GenDataTaskResult: """Run a gen data task.""" self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() - async with self._async_get_ai_task_chat_log(task) as chat_log: + async with self._async_get_ai_task_chat_log(session, task) as chat_log: return await self._async_generate_data(task, chat_log) async def _async_generate_data( diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json index c685410530d..ea377ffa671 100644 --- a/homeassistant/components/ai_task/manifest.json +++ b/homeassistant/components/ai_task/manifest.json @@ -1,8 +1,9 @@ { "domain": "ai_task", "name": "AI Task", + "after_dependencies": ["camera"], "codeowners": ["@home-assistant/core"], - "dependencies": ["conversation"], + "dependencies": ["conversation", "media_source"], "documentation": "https://www.home-assistant.io/integrations/ai_task", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index a531ca599b1..feefa70a30b 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -10,10 +10,24 @@ generate_data: required: true selector: text: + multiline: true entity_id: required: false selector: entity: - domain: ai_task - supported_features: - - ai_task.AITaskEntityFeature.GENERATE_DATA + filter: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_DATA + structure: + advanced: true + required: false + example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }' + selector: + object: + attachments: + required: false + selector: + media: + accept: + - "*" diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index 877174de681..261381b7c31 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -15,6 +15,14 @@ "entity_id": { "name": "Entity ID", "description": "Entity ID to run the task on. If not provided, the preferred entity will be used." + }, + "structure": { + "name": "Structured output", + "description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field." + }, + "attachments": { + "name": "Attachments", + "description": "List of files to attach for multi-modal AI analysis." } } } diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 2e546897602..3cc43f8c07a 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -3,20 +3,40 @@ from __future__ import annotations from dataclasses import dataclass +import mimetypes +from pathlib import Path +import tempfile from typing import Any -from homeassistant.core import HomeAssistant +import voluptuous as vol + +from homeassistant.components import camera, conversation, media_source +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.chat_session import async_get_chat_session from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature +def _save_camera_snapshot(image: camera.Image) -> Path: + """Save camera snapshot to temp file.""" + with tempfile.NamedTemporaryFile( + mode="wb", + suffix=mimetypes.guess_extension(image.content_type, False), + delete=False, + ) as temp_file: + temp_file.write(image.content) + return Path(temp_file.name) + + async def async_generate_data( hass: HomeAssistant, *, task_name: str, entity_id: str | None = None, instructions: str, + structure: vol.Schema | None = None, + attachments: list[dict] | None = None, ) -> GenDataTaskResult: """Run a task in the AI Task integration.""" if entity_id is None: @@ -34,12 +54,80 @@ async def async_generate_data( f"AI Task entity {entity_id} does not support generating data" ) - return await entity.internal_async_generate_data( - GenDataTask( - name=task_name, - instructions=instructions, + # Resolve attachments + resolved_attachments: list[conversation.Attachment] = [] + created_files: list[Path] = [] + + if ( + attachments + and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features + ): + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support attachments" + ) + + for attachment in attachments or []: + media_content_id = attachment["media_content_id"] + + # Special case for camera media sources + if media_content_id.startswith("media-source://camera/"): + # Extract entity_id from the media content ID + entity_id = media_content_id.removeprefix("media-source://camera/") + + # Get snapshot from camera + image = await camera.async_get_image(hass, entity_id) + + temp_filename = await hass.async_add_executor_job( + _save_camera_snapshot, image + ) + created_files.append(temp_filename) + + resolved_attachments.append( + conversation.Attachment( + media_content_id=media_content_id, + mime_type=image.content_type, + path=temp_filename, + ) + ) + else: + # Handle regular media sources + media = await media_source.async_resolve_media(hass, media_content_id, None) + if media.path is None: + raise HomeAssistantError( + "Only local attachments are currently supported" + ) + resolved_attachments.append( + conversation.Attachment( + media_content_id=media_content_id, + mime_type=media.mime_type, + path=media.path, + ) + ) + + with async_get_chat_session(hass) as session: + if created_files: + + def cleanup_files() -> None: + """Cleanup temporary files.""" + for file in created_files: + file.unlink(missing_ok=True) + + @callback + def cleanup_files_callback() -> None: + """Cleanup temporary files.""" + hass.async_add_executor_job(cleanup_files) + + session.async_on_cleanup(cleanup_files_callback) + + return await entity.internal_async_generate_data( + session, + GenDataTask( + name=task_name, + instructions=instructions, + structure=structure, + attachments=resolved_attachments or None, + ), ) - ) @dataclass(slots=True) @@ -52,6 +140,12 @@ class GenDataTask: instructions: str """Instructions on what needs to be done.""" + structure: vol.Schema | None = None + """Optional structure for the data to be generated.""" + + attachments: list[conversation.Attachment] | None = None + """List of attachments to go along the instructions.""" + def __str__(self) -> str: """Return task as a string.""" return f"" diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index afaf2698ced..3011e0602c9 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["airgradient==0.9.2"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 7a7f8d5ee1d..ec2e200b0a7 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -14,9 +14,9 @@ rules: status: exempt comment: | This integration does not provide additional actions. - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -34,7 +34,7 @@ rules: docs-configuration-parameters: status: exempt comment: No options to configure - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -43,23 +43,19 @@ rules: status: exempt comment: | This integration does not require authentication. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: DHCP is still possible - discovery: - status: todo - comment: DHCP is still possible - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index cef4db57358..6342fa5392a 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -61,7 +61,7 @@ "display_pm_standard": { "name": "Display PM standard", "state": { - "ugm3": "µg/m³", + "ugm3": "μg/m³", "us_aqi": "US AQI" } }, diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 6fb7e90502f..2881469b968 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -45,9 +45,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo # Store Entity and Initialize Platforms entry.runtime_data = coordinator - # Listen for option changes - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Clean up unused device entries with no entities @@ -88,8 +85,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 7cd113125a8..661e1b0a298 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -126,7 +126,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): return AirNowOptionsFlowHandler() -class AirNowOptionsFlowHandler(OptionsFlow): +class AirNowOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for AirNow.""" async def async_step_init( diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 1e73bc7551e..12085f1188e 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - data = {} + data: dict[str, Any] = {} try: obs = await self.airnow.observations.latLong( self.latitude, diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index 28dada485b2..41df51715fc 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airnow", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.2.1"] + "requirements": ["pyairnow==1.3.1"] } diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py new file mode 100644 index 00000000000..ea184e5613d --- /dev/null +++ b/homeassistant/components/airos/__init__.py @@ -0,0 +1,45 @@ +"""The Ubiquiti airOS integration.""" + +from __future__ import annotations + +from airos.airos8 import AirOS + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: + """Set up Ubiquiti airOS from a config entry.""" + + # By default airOS 8 comes with self-signed SSL certificates, + # with no option in the web UI to change or upload a custom certificate. + session = async_get_clientsession(hass, verify_ssl=False) + + airos_device = AirOS( + host=entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) + + coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py new file mode 100644 index 00000000000..e743cda4c63 --- /dev/null +++ b/homeassistant/components/airos/binary_sensor.py @@ -0,0 +1,106 @@ +"""AirOS Binary Sensor component for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +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 AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator +from .entity import AirOSEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe an AirOS binary sensor.""" + + value_fn: Callable[[AirOSData], bool] + + +BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( + AirOSBinarySensorEntityDescription( + key="portfw", + translation_key="port_forwarding", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.portfw, + ), + AirOSBinarySensorEntityDescription( + key="dhcp_client", + translation_key="dhcp_client", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.dhcpc, + ), + AirOSBinarySensorEntityDescription( + key="dhcp_server", + translation_key="dhcp_server", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + 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", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.pppoe, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """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 + ) + + +class AirOSBinarySensor(AirOSEntity, BinarySensorEntity): + """Representation of a binary sensor.""" + + entity_description: AirOSBinarySensorEntityDescription + + def __init__( + self, + coordinator: AirOSDataUpdateCoordinator, + description: AirOSBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py new file mode 100644 index 00000000000..8df93c7b2c4 --- /dev/null +++ b/homeassistant/components/airos/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for the Ubiquiti airOS integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from airos.exceptions import ( + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import AirOS + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default="ubnt"): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ubiquiti airOS.""" + + 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: + # By default airOS 8 comes with self-signed SSL certificates, + # with no option in the web UI to change or upload a custom certificate. + session = async_get_clientsession(self.hass, verify_ssl=False) + + airos_device = AirOS( + host=user_input[CONF_HOST], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + session=session, + ) + try: + await airos_device.login() + airos_data = await airos_device.status() + + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + ): + errors["base"] = "cannot_connect" + except (AirOSConnectionAuthenticationError, AirOSDataMissingError): + errors["base"] = "invalid_auth" + except AirOSKeyDataMissingError: + errors["base"] = "key_data_missing" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(airos_data.derived.mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=airos_data.host.hostname, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py new file mode 100644 index 00000000000..f4be2594613 --- /dev/null +++ b/homeassistant/components/airos/const.py @@ -0,0 +1,9 @@ +"""Constants for the Ubiquiti airOS integration.""" + +from datetime import timedelta + +DOMAIN = "airos" + +SCAN_INTERVAL = timedelta(minutes=1) + +MANUFACTURER = "Ubiquiti" diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py new file mode 100644 index 00000000000..2fe675ee76a --- /dev/null +++ b/homeassistant/components/airos/coordinator.py @@ -0,0 +1,70 @@ +"""DataUpdateCoordinator for AirOS.""" + +from __future__ import annotations + +import logging + +from airos.airos8 import AirOS, AirOSData +from airos.exceptions import ( + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] + + +class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]): + """Class to manage fetching AirOS data from single endpoint.""" + + config_entry: AirOSConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS + ) -> None: + """Initialize the coordinator.""" + self.airos_device = airos_device + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> AirOSData: + """Fetch data from AirOS.""" + try: + await self.airos_device.login() + return await self.airos_device.status() + except (AirOSConnectionAuthenticationError,) as err: + _LOGGER.exception("Error authenticating with airOS device") + raise ConfigEntryError( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: + _LOGGER.error("Error connecting to airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except (AirOSDataMissingError,) as err: + _LOGGER.error("Expected data not returned by airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_data_missing", + ) from err diff --git a/homeassistant/components/airos/diagnostics.py b/homeassistant/components/airos/diagnostics.py new file mode 100644 index 00000000000..70fef685c86 --- /dev/null +++ b/homeassistant/components/airos/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for airOS.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import AirOSConfigEntry + +IP_REDACT = ["addr", "ipaddr", "ip6addr", "lastip"] # IP related +HW_REDACT = ["apmac", "hwaddr", "mac"] # MAC address +TO_REDACT_HA = [CONF_HOST, CONF_PASSWORD] +TO_REDACT_AIROS = [ + "hostname", # Prevent leaking device naming + "essid", # Network SSID + "lat", # GPS latitude to prevent exposing location data. + "lon", # GPS longitude to prevent exposing location data. + *HW_REDACT, + *IP_REDACT, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AirOSConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "entry_data": async_redact_data(entry.data, TO_REDACT_HA), + "data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS), + } diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py new file mode 100644 index 00000000000..e54962110fc --- /dev/null +++ b/homeassistant/components/airos/entity.py @@ -0,0 +1,36 @@ +"""Generic AirOS Entity Class.""" + +from __future__ import annotations + +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import AirOSDataUpdateCoordinator + + +class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]): + """Represent a AirOS Entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: + """Initialise the gateway.""" + super().__init__(coordinator) + + airos_data = self.coordinator.data + + configuration_url: str | None = ( + f"https://{coordinator.config_entry.data[CONF_HOST]}" + ) + + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)}, + configuration_url=configuration_url, + identifiers={(DOMAIN, str(airos_data.host.device_id))}, + manufacturer=MANUFACTURER, + model=airos_data.host.devmodel, + name=airos_data.host.hostname, + sw_version=airos_data.host.fwversion, + ) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json new file mode 100644 index 00000000000..5699d082956 --- /dev/null +++ b/homeassistant/components/airos/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airos", + "name": "Ubiquiti airOS", + "codeowners": ["@CoMPaTech"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airos", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["airos==0.3.0"] +} diff --git a/homeassistant/components/airos/quality_scale.yaml b/homeassistant/components/airos/quality_scale.yaml new file mode 100644 index 00000000000..e8a5ce8ed89 --- /dev/null +++ b/homeassistant/components/airos/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: airOS does not have 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: airOS does not have actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: local_polling without 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: airOS does not have actions + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: no (custom) icons used or envisioned + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py new file mode 100644 index 00000000000..06b06a21e28 --- /dev/null +++ b/homeassistant/components/airos/sensor.py @@ -0,0 +1,194 @@ +"""AirOS Sensor component for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, + UnitOfDataRate, + UnitOfFrequency, + UnitOfLength, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator +from .entity import AirOSEntity + +_LOGGER = logging.getLogger(__name__) + +NETROLE_OPTIONS = [mode.value for mode in NetRole] +WIRELESS_MODE_OPTIONS = [mode.value for mode in DerivedWirelessMode] +WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole] + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirOSSensorEntityDescription(SensorEntityDescription): + """Describe an AirOS sensor.""" + + value_fn: Callable[[AirOSData], StateType] + + +SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( + AirOSSensorEntityDescription( + key="host_cpuload", + translation_key="host_cpuload", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.host.cpuload, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="host_netrole", + translation_key="host_netrole", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.host.netrole.value, + options=NETROLE_OPTIONS, + ), + AirOSSensorEntityDescription( + key="wireless_frequency", + translation_key="wireless_frequency", + native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.frequency, + ), + AirOSSensorEntityDescription( + key="wireless_essid", + 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", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda data: data.host.uptime, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_distance", + translation_key="wireless_distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfLength.KILOMETERS, + value_fn=lambda data: data.wireless.distance, + ), + AirOSSensorEntityDescription( + key="wireless_mode", + translation_key="wireless_mode", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.mode.value, + options=WIRELESS_MODE_OPTIONS, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="wireless_role", + translation_key="wireless_role", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.derived.role.value, + options=WIRELESS_ROLE_OPTIONS, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the AirOS sensors from a config entry.""" + coordinator = config_entry.runtime_data + + async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS) + + +class AirOSSensor(AirOSEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: AirOSSensorEntityDescription + + def __init__( + self, + coordinator: AirOSDataUpdateCoordinator, + description: AirOSSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" + + @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/airos/strings.json b/homeassistant/components/airos/strings.json new file mode 100644 index 00000000000..53681292f50 --- /dev/null +++ b/homeassistant/components/airos/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "flow_title": "Ubiquiti airOS device", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "IP address or hostname of the airOS device", + "username": "Administrator username for the airOS device, normally 'ubnt'", + "password": "Password configured through the UISP app or web interface" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "key_data_missing": "Expected data not returned from the device, check the documentation for supported devices", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "binary_sensor": { + "port_forwarding": { + "name": "Port forwarding" + }, + "dhcp_client": { + "name": "DHCP client" + }, + "dhcp_server": { + "name": "DHCP server" + }, + "dhcp6_server": { + "name": "DHCPv6 server" + }, + "pppoe": { + "name": "PPPoE link" + } + }, + "sensor": { + "host_cpuload": { + "name": "CPU load" + }, + "host_netrole": { + "name": "Network role", + "state": { + "bridge": "Bridge", + "router": "Router" + } + }, + "wireless_frequency": { + "name": "Wireless frequency" + }, + "wireless_essid": { + "name": "Wireless SSID" + }, + "wireless_antenna_gain": { + "name": "Antenna gain" + }, + "wireless_throughput_tx": { + "name": "Throughput transmit (actual)" + }, + "wireless_throughput_rx": { + "name": "Throughput receive (actual)" + }, + "wireless_polling_dl_capacity": { + "name": "Download capacity" + }, + "wireless_polling_ul_capacity": { + "name": "Upload capacity" + }, + "wireless_remote_hostname": { + "name": "Remote hostname" + }, + "host_uptime": { + "name": "Uptime" + }, + "wireless_distance": { + "name": "Wireless distance" + }, + "wireless_role": { + "name": "Wireless role", + "state": { + "access_point": "Access point", + "station": "Station" + } + }, + "wireless_mode": { + "name": "Wireless mode", + "state": { + "point_to_point": "Point-to-point", + "point_to_multipoint": "Point-to-multipoint" + } + } + } + }, + "exceptions": { + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "key_data_missing": { + "message": "Key data not returned from device" + }, + "error_data_missing": { + "message": "Data incomplete or missing" + } + } +} diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index ab64915c8ae..f87365797e7 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .coordinator import AirQCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] AirQConfigEntry = ConfigEntry[AirQCoordinator] diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 7a5abe47a8d..3e5c736c8c5 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -6,6 +6,5 @@ CONF_RETURN_AVERAGE: Final = "return_average" CONF_CLIP_NEGATIVE: Final = "clip_negatives" DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 743d12d40e5..7c62a023a11 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator): name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - session = async_get_clientsession(hass) + session = async_create_clientsession(hass) self.airq = AirQ( entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session ) @@ -75,6 +75,7 @@ class AirQCoordinator(DataUpdateCoordinator): return_average=self.return_average, clip_negative_values=self.clip_negative, ) + data["brightness"] = await self.airq.get_current_brightness() if warming_up_sensors := identify_warming_up_sensors(data): _LOGGER.debug( "Following sensors are still warming up: %s", warming_up_sensors diff --git a/homeassistant/components/airq/icons.json b/homeassistant/components/airq/icons.json index fec6eb8dd86..09f262aeaaf 100644 --- a/homeassistant/components/airq/icons.json +++ b/homeassistant/components/airq/icons.json @@ -4,9 +4,6 @@ "health_index": { "default": "mdi:heart-pulse" }, - "absolute_humidity": { - "default": "mdi:water" - }, "oxygen": { "default": "mdi:leaf" }, diff --git a/homeassistant/components/airq/number.py b/homeassistant/components/airq/number.py new file mode 100644 index 00000000000..e980760ed52 --- /dev/null +++ b/homeassistant/components/airq/number.py @@ -0,0 +1,85 @@ +"""Definition of air-Q number platform used to control the LED strips.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging + +from aioairq.core import AirQ + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +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 . import AirQConfigEntry, AirQCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class AirQBrightnessDescription(NumberEntityDescription): + """Describes AirQ number entity responsible for brightness control.""" + + value: Callable[[dict], float] + set_value: Callable[[AirQ, float], Awaitable[None]] + + +AIRQ_LED_BRIGHTNESS = AirQBrightnessDescription( + key="airq_led_brightness", + translation_key="airq_led_brightness", + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + native_unit_of_measurement=PERCENTAGE, + value=lambda data: data["brightness"], + set_value=lambda device, value: device.set_current_brightness(value), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirQConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number entities: a single entity for the LEDs.""" + + coordinator = entry.runtime_data + entities = [AirQLEDBrightness(coordinator, AIRQ_LED_BRIGHTNESS)] + + async_add_entities(entities) + + +class AirQLEDBrightness(CoordinatorEntity[AirQCoordinator], NumberEntity): + """Representation of the LEDs from a single AirQ.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirQCoordinator, + description: AirQBrightnessDescription, + ) -> None: + """Initialize a single sensor.""" + super().__init__(coordinator) + self.entity_description: AirQBrightnessDescription = description + + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the brightness of the LEDs in %.""" + return self.entity_description.value(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set the brightness of the LEDs to the value in %.""" + _LOGGER.debug( + "Changing LED brighntess from %.0f%% to %.0f%%", + self.coordinator.data["brightness"], + value, + ) + await self.entity_description.set_value(self.coordinator.airq, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 08a344ae9f4..516114840d3 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -28,10 +29,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirQConfigEntry, AirQCoordinator -from .const import ( - ACTIVITY_BECQUEREL_PER_CUBIC_METER, - CONCENTRATION_GRAMS_PER_CUBIC_METER, -) +from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER _LOGGER = logging.getLogger(__name__) @@ -195,7 +193,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity_abs", - translation_key="absolute_humidity", + device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY, native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 9c16975a3ab..2972ba5c15b 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -35,6 +35,11 @@ } }, "entity": { + "number": { + "airq_led_brightness": { + "name": "LED brightness" + } + }, "sensor": { "acetaldehyde": { "name": "Acetaldehyde" @@ -93,9 +98,6 @@ "health_index": { "name": "Health index" }, - "absolute_humidity": { - "name": "Absolute humidity" - }, "hydrogen": { "name": "Hydrogen" }, diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index 175fd320062..04c666dc5bc 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -7,21 +7,18 @@ import logging from airthings import Airthings -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_SECRET -from .coordinator import AirthingsDataUpdateCoordinator +from .coordinator import AirthingsConfigEntry, AirthingsDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=6) -type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool: """Set up Airthings from a config entry.""" @@ -31,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> async_get_clientsession(hass), ) - coordinator = AirthingsDataUpdateCoordinator(hass, airthings) + coordinator = AirthingsDataUpdateCoordinator(hass, airthings, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index ab453ede20c..23711b7a9a2 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -45,6 +45,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): ) errors = {} + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() try: await airthings.get_token( @@ -60,9 +62,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_ID]) - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Airthings", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/airthings/coordinator.py b/homeassistant/components/airthings/coordinator.py index 6172dc0b6ef..9e15e4a0c5d 100644 --- a/homeassistant/components/airthings/coordinator.py +++ b/homeassistant/components/airthings/coordinator.py @@ -5,6 +5,7 @@ import logging from airthings import Airthings, AirthingsDevice, AirthingsError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,23 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=6) +type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator] + class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]): """Coordinator for Airthings data updates.""" - def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None: + def __init__( + self, + hass: HomeAssistant, + airthings: Airthings, + config_entry: AirthingsConfigEntry, + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_method=self._update_method, update_interval=SCAN_INTERVAL, diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index ff30fb2f2ae..45e532268c0 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -150,7 +150,7 @@ async def async_setup_entry( coordinator = entry.runtime_data entities = [ - AirthingsHeaterEnergySensor( + AirthingsDeviceSensor( coordinator, airthings_device, SENSORS[sensor_types], @@ -162,7 +162,7 @@ async def async_setup_entry( async_add_entities(entities) -class AirthingsHeaterEnergySensor( +class AirthingsDeviceSensor( CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity ): """Representation of a Airthings Sensor device.""" diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ecc9634f36a..8f89ec88271 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.12"] + "requirements": ["aioairzone-cloud==0.7.1"] } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7088b624e21..5f789813869 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity): ): yield AlexaThermostatController(self.hass, self.entity) yield AlexaTemperatureSensor(self.hass, self.entity) - if self.entity.domain == water_heater.DOMAIN and ( - supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + if ( + self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) ): yield AlexaModeController( self.entity, @@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity): self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) force_range_controller = False - if supported & fan.FanEntityFeature.PRESET_MODE: + if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get( + fan.ATTR_PRESET_MODES + ): yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" ) @@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity): yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] - if activities and supported & remote.RemoteEntityFeature.ACTIVITY: + if ( + activities + and (supported & remote.RemoteEntityFeature.ACTIVITY) + and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + ): yield AlexaModeController( self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" ) @@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity): """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & humidifier.HumidifierEntityFeature.MODES: + if ( + supported & humidifier.HumidifierEntityFeature.MODES + ) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES): yield AlexaModeController( self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" ) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index aff4c1bb391..9df0e60850e 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -2,8 +2,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, @@ -12,11 +16,20 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Alexa Devices component.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Set up Alexa Devices platform.""" - coordinator = AmazonDevicesCoordinator(hass, entry) + session = aiohttp_client.async_create_clientsession(hass) + coordinator = AmazonDevicesCoordinator(hass, entry, session) await coordinator.async_config_entry_first_refresh() @@ -29,5 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Unload a config entry.""" - await entry.runtime_data.api.close() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 16cf73aee9f..231f144dd89 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Final from aioamazondevices.api import AmazonDevice +from aioamazondevices.const import SENSOR_STATE_OFF from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -28,7 +29,8 @@ PARALLEL_UPDATES = 0 class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): """Alexa Devices binary sensor entity description.""" - is_on_fn: Callable[[AmazonDevice], bool] + is_on_fn: Callable[[AmazonDevice, str], bool] + is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True BINARY_SENSORS: Final = ( @@ -36,13 +38,49 @@ BINARY_SENSORS: Final = ( key="online", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda _device: _device.online, + is_on_fn=lambda device, _: device.online, ), AmazonBinarySensorEntityDescription( key="bluetooth", entity_category=EntityCategory.DIAGNOSTIC, translation_key="bluetooth", - is_on_fn=lambda _device: _device.bluetooth_state, + is_on_fn=lambda device, _: device.bluetooth_state, + ), + AmazonBinarySensorEntityDescription( + key="babyCryDetectionState", + translation_key="baby_cry_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="beepingApplianceDetectionState", + translation_key="beeping_appliance_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="coughDetectionState", + translation_key="cough_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="dogBarkDetectionState", + translation_key="dog_bark_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="humanPresenceDetectionState", + device_class=BinarySensorDeviceClass.MOTION, + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="waterSoundsDetectionState", + translation_key="water_sounds_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, ), ) @@ -60,6 +98,7 @@ async def async_setup_entry( AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) for sensor_desc in BINARY_SENSORS for serial_num in coordinator.data + if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) ) @@ -71,4 +110,6 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the binary sensor is on.""" - return self.entity_description.is_on_fn(self.device) + return self.entity_description.is_on_fn( + self.device, self.entity_description.key + ) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index 5add7ceb711..3e705d73ade 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -2,19 +2,48 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from aioamazondevices.api import AmazonEchoApi -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + WrongCountry, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import CountrySelector from .const import CONF_LOGIN_DATA, DOMAIN +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CODE): cv.string, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + session = aiohttp_client.async_create_clientsession(hass) + api = AmazonEchoApi( + session, + data[CONF_COUNTRY], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) + + return await api.login_mode_interactive(data[CONF_CODE]) + class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Alexa Devices.""" @@ -25,17 +54,16 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input: - client = AmazonEchoApi( - user_input[CONF_COUNTRY], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) try: - data = await client.login_mode_interactive(user_input[CONF_CODE]) + data = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except CannotRetrieveData: + errors["base"] = "cannot_retrieve_data" + except WrongCountry: + errors["base"] = "wrong_country" else: await self.async_set_unique_id(data["customer_info"]["user_id"]) self._abort_if_unique_id_configured() @@ -44,8 +72,6 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): title=user_input[CONF_USERNAME], data=user_input | {CONF_LOGIN_DATA: data}, ) - finally: - await client.close() return self.async_show_form( step_id="user", @@ -61,3 +87,45 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): } ), ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + self.context["title_placeholders"] = {CONF_USERNAME: entry_data[CONF_USERNAME]} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirm.""" + errors: dict[str, str] = {} + + reauth_entry = self._get_reauth_entry() + entry_data = reauth_entry.data + + if user_input is not None: + try: + await validate_input(self.hass, {**reauth_entry.data, **user_input}) + except CannotConnect: + errors["base"] = "cannot_connect" + except CannotAuthenticate: + errors["base"] = "invalid_auth" + except CannotRetrieveData: + errors["base"] = "cannot_retrieve_data" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data={ + CONF_USERNAME: entry_data[CONF_USERNAME], + CONF_PASSWORD: entry_data[CONF_PASSWORD], + CONF_CODE: user_input[CONF_CODE], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_USERNAME: entry_data[CONF_USERNAME]}, + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 8e58441d46c..f4a1faa4f81 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -8,14 +8,15 @@ from aioamazondevices.exceptions import ( CannotConnect, CannotRetrieveData, ) +from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, CONF_LOGIN_DATA +from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN SCAN_INTERVAL = 30 @@ -31,6 +32,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): self, hass: HomeAssistant, entry: AmazonConfigEntry, + session: ClientSession, ) -> None: """Initialize the scanner.""" super().__init__( @@ -41,6 +43,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): update_interval=timedelta(seconds=SCAN_INTERVAL), ) self.api = AmazonEchoApi( + session, entry.data[CONF_COUNTRY], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], @@ -52,7 +55,21 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): try: await self.api.login_mode_stored_data() return await self.api.get_devices_data() - except (CannotConnect, CannotRetrieveData) as err: - raise UpdateFailed(f"Error occurred while updating {self.name}") from err + except CannotConnect as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err except CannotAuthenticate as err: - raise ConfigEntryError("Could not authenticate") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json index e3b20eb2c4a..bedd4af1734 100644 --- a/homeassistant/components/alexa_devices/icons.json +++ b/homeassistant/components/alexa_devices/icons.json @@ -2,11 +2,49 @@ "entity": { "binary_sensor": { "bluetooth": { - "default": "mdi:bluetooth", + "default": "mdi:bluetooth-off", "state": { - "off": "mdi:bluetooth-off" + "on": "mdi:bluetooth" + } + }, + "baby_cry_detection": { + "default": "mdi:account-voice-off", + "state": { + "on": "mdi:account-voice" + } + }, + "beeping_appliance_detection": { + "default": "mdi:bell-off", + "state": { + "on": "mdi:bell-ring" + } + }, + "cough_detection": { + "default": "mdi:blur-off", + "state": { + "on": "mdi:blur" + } + }, + "dog_bark_detection": { + "default": "mdi:dog-side-off", + "state": { + "on": "mdi:dog-side" + } + }, + "water_sounds_detection": { + "default": "mdi:water-pump-off", + "state": { + "on": "mdi:water-pump" } } } + }, + "services": { + "send_sound": { + "service": "mdi:cast-audio" + }, + "send_text_command": { + "service": "mdi:microphone-message" + } } } diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index a2bb423860b..90410412dfa 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], - "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.1.14"] + "quality_scale": "silver", + "requirements": ["aioamazondevices==4.0.0"] } diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 46db294377a..08f2e214f38 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity): entity_description: AmazonNotifyEntityDescription + @alexa_api_call async def async_send_message( self, message: str, title: str | None = None, **kwargs: Any ) -> None: diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 881a02bc6d3..5a2ff55b9b2 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -26,41 +26,39 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo - test-coverage: - status: todo - comment: all tests missing + reauthentication-flow: done + test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Network information not relevant discovery: status: exempt comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration - 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 + 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: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: @@ -72,5 +70,5 @@ rules: # Platinum async-dependency: done - inject-websession: todo + inject-websession: done strict-typing: done diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py new file mode 100644 index 00000000000..5463c7a4319 --- /dev/null +++ b/homeassistant/components/alexa_devices/services.py @@ -0,0 +1,121 @@ +"""Support for services.""" + +from aioamazondevices.sounds import SOUNDS_LIST +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import DOMAIN +from .coordinator import AmazonConfigEntry + +ATTR_TEXT_COMMAND = "text_command" +ATTR_SOUND = "sound" +ATTR_SOUND_VARIANT = "sound_variant" +SERVICE_TEXT_COMMAND = "send_text_command" +SERVICE_SOUND_NOTIFICATION = "send_sound" + +SCHEMA_SOUND_SERVICE = vol.Schema( + { + vol.Required(ATTR_SOUND): cv.string, + vol.Required(ATTR_SOUND_VARIANT): cv.positive_int, + vol.Required(ATTR_DEVICE_ID): cv.string, + }, +) +SCHEMA_CUSTOM_COMMAND = vol.Schema( + { + vol.Required(ATTR_TEXT_COMMAND): cv.string, + vol.Required(ATTR_DEVICE_ID): cv.string, + } +) + + +@callback +def async_get_entry_id_for_service_call( + call: ServiceCall, +) -> tuple[dr.DeviceEntry, AmazonConfigEntry]: + """Get the entry ID related to a service call (by device ID).""" + device_registry = dr.async_get(call.hass) + device_id = call.data[ATTR_DEVICE_ID] + if (device_entry := device_registry.async_get(device_id)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + for entry_id in device_entry.config_entries: + if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None: + continue + if entry.domain == DOMAIN: + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + translation_placeholders={"entry": entry.title}, + ) + return (device_entry, entry) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"device_id": device_id}, + ) + + +async def _async_execute_action(call: ServiceCall, attribute: str) -> None: + """Execute action on the device.""" + device, config_entry = async_get_entry_id_for_service_call(call) + assert device.serial_number + value: str = call.data[attribute] + + coordinator = config_entry.runtime_data + + if attribute == ATTR_SOUND: + variant: int = call.data[ATTR_SOUND_VARIANT] + pad = "_" if variant > 10 else "_0" + file = f"{value}{pad}{variant!s}" + if value not in SOUNDS_LIST or variant > SOUNDS_LIST[value]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_value", + translation_placeholders={"sound": value, "variant": str(variant)}, + ) + await coordinator.api.call_alexa_sound( + coordinator.data[device.serial_number], file + ) + elif attribute == ATTR_TEXT_COMMAND: + await coordinator.api.call_alexa_text_command( + coordinator.data[device.serial_number], value + ) + + +async def async_send_sound_notification(call: ServiceCall) -> None: + """Send a sound notification to a AmazonDevice.""" + await _async_execute_action(call, ATTR_SOUND) + + +async def async_send_text_command(call: ServiceCall) -> None: + """Send a custom command to a AmazonDevice.""" + await _async_execute_action(call, ATTR_TEXT_COMMAND) + + +@callback +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, + async_send_sound_notification, + SCHEMA_SOUND_SERVICE, + ), + ( + SERVICE_TEXT_COMMAND, + async_send_text_command, + SCHEMA_CUSTOM_COMMAND, + ), + ): + hass.services.async_register(DOMAIN, service_name, method, schema=schema) diff --git a/homeassistant/components/alexa_devices/services.yaml b/homeassistant/components/alexa_devices/services.yaml new file mode 100644 index 00000000000..d9eef28aea2 --- /dev/null +++ b/homeassistant/components/alexa_devices/services.yaml @@ -0,0 +1,504 @@ +send_text_command: + fields: + device_id: + required: true + selector: + device: + integration: alexa_devices + text_command: + required: true + example: "Play B.B.C. on TuneIn" + selector: + text: + +send_sound: + fields: + device_id: + required: true + selector: + device: + integration: alexa_devices + sound_variant: + required: true + example: 1 + default: 1 + selector: + number: + min: 1 + max: 50 + sound: + required: true + example: amzn_sfx_doorbell_chime + default: amzn_sfx_doorbell_chime + selector: + select: + options: + - air_horn + - air_horns + - airboat + - airport + - aliens + - amzn_sfx_airplane_takeoff_whoosh + - amzn_sfx_army_march_clank_7x + - amzn_sfx_army_march_large_8x + - amzn_sfx_army_march_small_8x + - amzn_sfx_baby_big_cry + - amzn_sfx_baby_cry + - amzn_sfx_baby_fuss + - amzn_sfx_battle_group_clanks + - amzn_sfx_battle_man_grunts + - amzn_sfx_battle_men_grunts + - amzn_sfx_battle_men_horses + - amzn_sfx_battle_noisy_clanks + - amzn_sfx_battle_yells_men + - amzn_sfx_battle_yells_men_run + - amzn_sfx_bear_groan_roar + - amzn_sfx_bear_roar_grumble + - amzn_sfx_bear_roar_small + - amzn_sfx_beep_1x + - amzn_sfx_bell_med_chime + - amzn_sfx_bell_short_chime + - amzn_sfx_bell_timer + - amzn_sfx_bicycle_bell_ring + - amzn_sfx_bird_chickadee_chirp_1x + - amzn_sfx_bird_chickadee_chirps + - amzn_sfx_bird_forest + - amzn_sfx_bird_forest_short + - amzn_sfx_bird_robin_chirp_1x + - amzn_sfx_boing_long_1x + - amzn_sfx_boing_med_1x + - amzn_sfx_boing_short_1x + - amzn_sfx_bus_drive_past + - amzn_sfx_buzz_electronic + - amzn_sfx_buzzer_loud_alarm + - amzn_sfx_buzzer_small + - amzn_sfx_car_accelerate + - amzn_sfx_car_accelerate_noisy + - amzn_sfx_car_click_seatbelt + - amzn_sfx_car_close_door_1x + - amzn_sfx_car_drive_past + - amzn_sfx_car_honk_1x + - amzn_sfx_car_honk_2x + - amzn_sfx_car_honk_3x + - amzn_sfx_car_honk_long_1x + - amzn_sfx_car_into_driveway + - amzn_sfx_car_into_driveway_fast + - amzn_sfx_car_slam_door_1x + - amzn_sfx_car_undo_seatbelt + - amzn_sfx_cat_angry_meow_1x + - amzn_sfx_cat_angry_screech_1x + - amzn_sfx_cat_long_meow_1x + - amzn_sfx_cat_meow_1x + - amzn_sfx_cat_purr + - amzn_sfx_cat_purr_meow + - amzn_sfx_chicken_cluck + - amzn_sfx_church_bell_1x + - amzn_sfx_church_bells_ringing + - amzn_sfx_clear_throat_ahem + - amzn_sfx_clock_ticking + - amzn_sfx_clock_ticking_long + - amzn_sfx_copy_machine + - amzn_sfx_cough + - amzn_sfx_crow_caw_1x + - amzn_sfx_crowd_applause + - amzn_sfx_crowd_bar + - amzn_sfx_crowd_bar_rowdy + - amzn_sfx_crowd_boo + - amzn_sfx_crowd_cheer_med + - amzn_sfx_crowd_excited_cheer + - amzn_sfx_dog_med_bark_1x + - amzn_sfx_dog_med_bark_2x + - amzn_sfx_dog_med_bark_growl + - amzn_sfx_dog_med_growl_1x + - amzn_sfx_dog_med_woof_1x + - amzn_sfx_dog_small_bark_2x + - amzn_sfx_door_open + - amzn_sfx_door_shut + - amzn_sfx_doorbell + - amzn_sfx_doorbell_buzz + - amzn_sfx_doorbell_chime + - amzn_sfx_drinking_slurp + - amzn_sfx_drum_and_cymbal + - amzn_sfx_drum_comedy + - amzn_sfx_earthquake_rumble + - amzn_sfx_electric_guitar + - amzn_sfx_electronic_beep + - amzn_sfx_electronic_major_chord + - amzn_sfx_elephant + - amzn_sfx_elevator_bell_1x + - amzn_sfx_elevator_open_bell + - amzn_sfx_fairy_melodic_chimes + - amzn_sfx_fairy_sparkle_chimes + - amzn_sfx_faucet_drip + - amzn_sfx_faucet_running + - amzn_sfx_fireplace_crackle + - amzn_sfx_fireworks + - amzn_sfx_fireworks_firecrackers + - amzn_sfx_fireworks_launch + - amzn_sfx_fireworks_whistles + - amzn_sfx_food_frying + - amzn_sfx_footsteps + - amzn_sfx_footsteps_muffled + - amzn_sfx_ghost_spooky + - amzn_sfx_glass_on_table + - amzn_sfx_glasses_clink + - amzn_sfx_horse_gallop_4x + - amzn_sfx_horse_huff_whinny + - amzn_sfx_horse_neigh + - amzn_sfx_horse_neigh_low + - amzn_sfx_horse_whinny + - amzn_sfx_human_walking + - amzn_sfx_jar_on_table_1x + - amzn_sfx_kitchen_ambience + - amzn_sfx_large_crowd_cheer + - amzn_sfx_large_fire_crackling + - amzn_sfx_laughter + - amzn_sfx_laughter_giggle + - amzn_sfx_lightning_strike + - amzn_sfx_lion_roar + - amzn_sfx_magic_blast_1x + - amzn_sfx_monkey_calls_3x + - amzn_sfx_monkey_chimp + - amzn_sfx_monkeys_chatter + - amzn_sfx_motorcycle_accelerate + - amzn_sfx_motorcycle_engine_idle + - amzn_sfx_motorcycle_engine_rev + - amzn_sfx_musical_drone_intro + - amzn_sfx_oars_splashing_rowboat + - amzn_sfx_object_on_table_2x + - amzn_sfx_ocean_wave_1x + - amzn_sfx_ocean_wave_on_rocks_1x + - amzn_sfx_ocean_wave_surf + - amzn_sfx_people_walking + - amzn_sfx_person_running + - amzn_sfx_piano_note_1x + - amzn_sfx_punch + - amzn_sfx_rain + - amzn_sfx_rain_on_roof + - amzn_sfx_rain_thunder + - amzn_sfx_rat_squeak_2x + - amzn_sfx_rat_squeaks + - amzn_sfx_raven_caw_1x + - amzn_sfx_raven_caw_2x + - amzn_sfx_restaurant_ambience + - amzn_sfx_rooster_crow + - amzn_sfx_scifi_air_escaping + - amzn_sfx_scifi_alarm + - amzn_sfx_scifi_alien_voice + - amzn_sfx_scifi_boots_walking + - amzn_sfx_scifi_close_large_explosion + - amzn_sfx_scifi_door_open + - amzn_sfx_scifi_engines_on + - amzn_sfx_scifi_engines_on_large + - amzn_sfx_scifi_engines_on_short_burst + - amzn_sfx_scifi_explosion + - amzn_sfx_scifi_explosion_2x + - amzn_sfx_scifi_incoming_explosion + - amzn_sfx_scifi_laser_gun_battle + - amzn_sfx_scifi_laser_gun_fires + - amzn_sfx_scifi_laser_gun_fires_large + - amzn_sfx_scifi_long_explosion_1x + - amzn_sfx_scifi_missile + - amzn_sfx_scifi_motor_short_1x + - amzn_sfx_scifi_open_airlock + - amzn_sfx_scifi_radar_high_ping + - amzn_sfx_scifi_radar_low + - amzn_sfx_scifi_radar_medium + - amzn_sfx_scifi_run_away + - amzn_sfx_scifi_sheilds_up + - amzn_sfx_scifi_short_low_explosion + - amzn_sfx_scifi_small_whoosh_flyby + - amzn_sfx_scifi_small_zoom_flyby + - amzn_sfx_scifi_sonar_ping_3x + - amzn_sfx_scifi_sonar_ping_4x + - amzn_sfx_scifi_spaceship_flyby + - amzn_sfx_scifi_timer_beep + - amzn_sfx_scifi_zap_backwards + - amzn_sfx_scifi_zap_electric + - amzn_sfx_sheep_baa + - amzn_sfx_sheep_bleat + - amzn_sfx_silverware_clank + - amzn_sfx_sirens + - amzn_sfx_sleigh_bells + - amzn_sfx_small_stream + - amzn_sfx_sneeze + - amzn_sfx_stream + - amzn_sfx_strong_wind_desert + - amzn_sfx_strong_wind_whistling + - amzn_sfx_subway_leaving + - amzn_sfx_subway_passing + - amzn_sfx_subway_stopping + - amzn_sfx_swoosh_cartoon_fast + - amzn_sfx_swoosh_fast_1x + - amzn_sfx_swoosh_fast_6x + - amzn_sfx_test_tone + - amzn_sfx_thunder_rumble + - amzn_sfx_toilet_flush + - amzn_sfx_trumpet_bugle + - amzn_sfx_turkey_gobbling + - amzn_sfx_typing_medium + - amzn_sfx_typing_short + - amzn_sfx_typing_typewriter + - amzn_sfx_vacuum_off + - amzn_sfx_vacuum_on + - amzn_sfx_walking_in_mud + - amzn_sfx_walking_in_snow + - amzn_sfx_walking_on_grass + - amzn_sfx_water_dripping + - amzn_sfx_water_droplets + - amzn_sfx_wind_strong_gusting + - amzn_sfx_wind_whistling_desert + - amzn_sfx_wings_flap_4x + - amzn_sfx_wings_flap_fast + - amzn_sfx_wolf_howl + - amzn_sfx_wolf_young_howl + - amzn_sfx_wooden_door + - amzn_sfx_wooden_door_creaks_long + - amzn_sfx_wooden_door_creaks_multiple + - amzn_sfx_wooden_door_creaks_open + - amzn_ui_sfx_gameshow_bridge + - amzn_ui_sfx_gameshow_countdown_loop_32s_full + - amzn_ui_sfx_gameshow_countdown_loop_64s_full + - amzn_ui_sfx_gameshow_countdown_loop_64s_minimal + - amzn_ui_sfx_gameshow_intro + - amzn_ui_sfx_gameshow_negative_response + - amzn_ui_sfx_gameshow_neutral_response + - amzn_ui_sfx_gameshow_outro + - amzn_ui_sfx_gameshow_player1 + - amzn_ui_sfx_gameshow_player2 + - amzn_ui_sfx_gameshow_player3 + - amzn_ui_sfx_gameshow_player4 + - amzn_ui_sfx_gameshow_positive_response + - amzn_ui_sfx_gameshow_tally_negative + - amzn_ui_sfx_gameshow_tally_positive + - amzn_ui_sfx_gameshow_waiting_loop_30s + - anchor + - answering_machines + - arcs_sparks + - arrows_bows + - baby + - back_up_beeps + - bars_restaurants + - baseball + - basketball + - battles + - beeps_tones + - bell + - bikes + - billiards + - board_games + - body + - boing + - books + - bow_wash + - box + - break_shatter_smash + - breaks + - brooms_mops + - bullets + - buses + - buzz + - buzz_hums + - buzzers + - buzzers_pistols + - cables_metal + - camera + - cannons + - car_alarm + - car_alarms + - car_cell_phones + - carnivals_fairs + - cars + - casino + - casinos + - cellar + - chimes + - chimes_bells + - chorus + - christmas + - church_bells + - clock + - cloth + - concrete + - construction + - construction_factory + - crashes + - crowds + - debris + - dining_kitchens + - dinosaurs + - dripping + - drops + - electric + - electrical + - elevator + - evolution_monsters + - explosions + - factory + - falls + - fax_scanner_copier + - feedback_mics + - fight + - fire + - fire_extinguisher + - fireballs + - fireworks + - fishing_pole + - flags + - football + - footsteps + - futuristic + - futuristic_ship + - gameshow + - gear + - ghosts_demons + - giant_monster + - glass + - glasses_clink + - golf + - gorilla + - grenade_lanucher + - griffen + - gyms_locker_rooms + - handgun_loading + - handgun_shot + - handle + - hands + - heartbeats_ekg + - helicopter + - high_tech + - hit_punch_slap + - hits + - horns + - horror + - hot_tub_filling_up + - human + - human_vocals + - hygene # codespell:ignore + - ice_skating + - ignitions + - infantry + - intro + - jet + - juggling + - key_lock + - kids + - knocks + - lab_equip + - lacrosse + - lamps_lanterns + - leather + - liquid_suction + - locker_doors + - machine_gun + - magic_spells + - medium_large_explosions + - metal + - modern_rings + - money_coins + - motorcycles + - movement + - moves + - nature + - oar_boat + - pagers + - paintball + - paper + - parachute + - pay_phones + - phone_beeps + - pigmy_bats + - pills + - pour_water + - power_up_down + - printers + - prison + - public_space + - racquetball + - radios_static + - rain + - rc_airplane + - rc_car + - refrigerators_freezers + - regular + - respirator + - rifle + - roller_coaster + - rollerskates_rollerblades + - room_tones + - ropes_climbing + - rotary_rings + - rowboat_canoe + - rubber + - running + - sails + - sand_gravel + - screen_doors + - screens + - seats_stools + - servos + - shoes_boots + - shotgun + - shower + - sink_faucet + - sink_filling_water + - sink_run_and_off + - sink_water_splatter + - sirens + - skateboards + - ski + - skids_tires + - sled + - slides + - small_explosions + - snow + - snowmobile + - soldiers + - splash_water + - splashes_sprays + - sports_whistles + - squeaks + - squeaky + - stairs + - steam + - submarine_diesel + - swing_doors + - switches_levers + - swords + - tape + - tape_machine + - televisions_shows + - tennis_pingpong + - textile + - throw + - thunder + - ticks + - timer + - toilet_flush + - tone + - tones_noises + - toys + - tractors + - traffic + - train + - trucks_vans + - turnstiles + - typing + - umbrella + - underwater + - vampires + - various + - video_tunes + - volcano_earthquake + - watches + - water + - water_running + - werewolves + - winches_gears + - wind + - wood + - wood_boat + - woosh + - zap + - zippers + translation_key: sound diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 9d615b248ed..1b1150d5649 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -1,21 +1,21 @@ { "common": { - "data_country": "Country code", "data_code": "One-time password (OTP code)", - "data_description_country": "The country of your Amazon account.", + "data_description_country": "The country where your Amazon account is registered.", "data_description_username": "The email address of your Amazon account.", "data_description_password": "The password of your Amazon account.", - "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." + "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported.", + "device_id_description": "The ID of the device to send the command to." }, "config": { "flow_title": "{username}", "step": { "user": { "data": { - "country": "[%key:component::alexa_devices::common::data_country%]", + "country": "[%key:common::config_flow::data::country%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "code": "[%key:component::alexa_devices::common::data_description_code%]" + "code": "[%key:component::alexa_devices::common::data_code%]" }, "data_description": { "country": "[%key:component::alexa_devices::common::data_description_country%]", @@ -23,17 +23,30 @@ "password": "[%key:component::alexa_devices::common::data_description_password%]", "code": "[%key:component::alexa_devices::common::data_description_code%]" } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "code": "[%key:component::alexa_devices::common::data_code%]" + }, + "data_description": { + "password": "[%key:component::alexa_devices::common::data_description_password%]", + "code": "[%key:component::alexa_devices::common::data_description_code%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_retrieve_data": "Unable to retrieve data from Amazon. Please try again later.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.", "unknown": "[%key:common::config_flow::error::unknown%]" } }, @@ -41,6 +54,21 @@ "binary_sensor": { "bluetooth": { "name": "Bluetooth" + }, + "baby_cry_detection": { + "name": "Baby crying" + }, + "beeping_appliance_detection": { + "name": "Beeping appliance" + }, + "cough_detection": { + "name": "Coughing" + }, + "dog_bark_detection": { + "name": "Dog barking" + }, + "water_sounds_detection": { + "name": "Water sounds" } }, "notify": { @@ -56,5 +84,533 @@ "name": "Do not disturb" } } + }, + "services": { + "send_sound": { + "name": "Send sound", + "description": "Sends a sound to a device", + "fields": { + "device_id": { + "name": "Device", + "description": "[%key:component::alexa_devices::common::device_id_description%]" + }, + "sound": { + "name": "Alexa Skill sound file", + "description": "The sound file to play." + }, + "sound_variant": { + "name": "Sound variant", + "description": "The variant of the sound to play." + } + } + }, + "send_text_command": { + "name": "Send text command", + "description": "Sends a text command to a device", + "fields": { + "text_command": { + "name": "Alexa text command", + "description": "The text command to send." + }, + "device_id": { + "name": "Device", + "description": "[%key:component::alexa_devices::common::device_id_description%]" + } + } + } + }, + "selector": { + "sound": { + "options": { + "air_horn": "Air Horn", + "air_horns": "Air Horns", + "airboat": "Airboat", + "airport": "Airport", + "aliens": "Aliens", + "amzn_sfx_airplane_takeoff_whoosh": "Airplane Takeoff Whoosh", + "amzn_sfx_army_march_clank_7x": "Army March Clank 7x", + "amzn_sfx_army_march_large_8x": "Army March Large 8x", + "amzn_sfx_army_march_small_8x": "Army March Small 8x", + "amzn_sfx_baby_big_cry": "Baby Big Cry", + "amzn_sfx_baby_cry": "Baby Cry", + "amzn_sfx_baby_fuss": "Baby Fuss", + "amzn_sfx_battle_group_clanks": "Battle Group Clanks", + "amzn_sfx_battle_man_grunts": "Battle Man Grunts", + "amzn_sfx_battle_men_grunts": "Battle Men Grunts", + "amzn_sfx_battle_men_horses": "Battle Men Horses", + "amzn_sfx_battle_noisy_clanks": "Battle Noisy Clanks", + "amzn_sfx_battle_yells_men": "Battle Yells Men", + "amzn_sfx_battle_yells_men_run": "Battle Yells Men Run", + "amzn_sfx_bear_groan_roar": "Bear Groan Roar", + "amzn_sfx_bear_roar_grumble": "Bear Roar Grumble", + "amzn_sfx_bear_roar_small": "Bear Roar Small", + "amzn_sfx_beep_1x": "Beep 1x", + "amzn_sfx_bell_med_chime": "Bell Med Chime", + "amzn_sfx_bell_short_chime": "Bell Short Chime", + "amzn_sfx_bell_timer": "Bell Timer", + "amzn_sfx_bicycle_bell_ring": "Bicycle Bell Ring", + "amzn_sfx_bird_chickadee_chirp_1x": "Bird Chickadee Chirp 1x", + "amzn_sfx_bird_chickadee_chirps": "Bird Chickadee Chirps", + "amzn_sfx_bird_forest": "Bird Forest", + "amzn_sfx_bird_forest_short": "Bird Forest Short", + "amzn_sfx_bird_robin_chirp_1x": "Bird Robin Chirp 1x", + "amzn_sfx_boing_long_1x": "Boing Long 1x", + "amzn_sfx_boing_med_1x": "Boing Med 1x", + "amzn_sfx_boing_short_1x": "Boing Short 1x", + "amzn_sfx_bus_drive_past": "Bus Drive Past", + "amzn_sfx_buzz_electronic": "Buzz Electronic", + "amzn_sfx_buzzer_loud_alarm": "Buzzer Loud Alarm", + "amzn_sfx_buzzer_small": "Buzzer Small", + "amzn_sfx_car_accelerate": "Car Accelerate", + "amzn_sfx_car_accelerate_noisy": "Car Accelerate Noisy", + "amzn_sfx_car_click_seatbelt": "Car Click Seatbelt", + "amzn_sfx_car_close_door_1x": "Car Close Door 1x", + "amzn_sfx_car_drive_past": "Car Drive Past", + "amzn_sfx_car_honk_1x": "Car Honk 1x", + "amzn_sfx_car_honk_2x": "Car Honk 2x", + "amzn_sfx_car_honk_3x": "Car Honk 3x", + "amzn_sfx_car_honk_long_1x": "Car Honk Long 1x", + "amzn_sfx_car_into_driveway": "Car Into Driveway", + "amzn_sfx_car_into_driveway_fast": "Car Into Driveway Fast", + "amzn_sfx_car_slam_door_1x": "Car Slam Door 1x", + "amzn_sfx_car_undo_seatbelt": "Car Undo Seatbelt", + "amzn_sfx_cat_angry_meow_1x": "Cat Angry Meow 1x", + "amzn_sfx_cat_angry_screech_1x": "Cat Angry Screech 1x", + "amzn_sfx_cat_long_meow_1x": "Cat Long Meow 1x", + "amzn_sfx_cat_meow_1x": "Cat Meow 1x", + "amzn_sfx_cat_purr": "Cat Purr", + "amzn_sfx_cat_purr_meow": "Cat Purr Meow", + "amzn_sfx_chicken_cluck": "Chicken Cluck", + "amzn_sfx_church_bell_1x": "Church Bell 1x", + "amzn_sfx_church_bells_ringing": "Church Bells Ringing", + "amzn_sfx_clear_throat_ahem": "Clear Throat Ahem", + "amzn_sfx_clock_ticking": "Clock Ticking", + "amzn_sfx_clock_ticking_long": "Clock Ticking Long", + "amzn_sfx_copy_machine": "Copy Machine", + "amzn_sfx_cough": "Cough", + "amzn_sfx_crow_caw_1x": "Crow Caw 1x", + "amzn_sfx_crowd_applause": "Crowd Applause", + "amzn_sfx_crowd_bar": "Crowd Bar", + "amzn_sfx_crowd_bar_rowdy": "Crowd Bar Rowdy", + "amzn_sfx_crowd_boo": "Crowd Boo", + "amzn_sfx_crowd_cheer_med": "Crowd Cheer Med", + "amzn_sfx_crowd_excited_cheer": "Crowd Excited Cheer", + "amzn_sfx_dog_med_bark_1x": "Dog Med Bark 1x", + "amzn_sfx_dog_med_bark_2x": "Dog Med Bark 2x", + "amzn_sfx_dog_med_bark_growl": "Dog Med Bark Growl", + "amzn_sfx_dog_med_growl_1x": "Dog Med Growl 1x", + "amzn_sfx_dog_med_woof_1x": "Dog Med Woof 1x", + "amzn_sfx_dog_small_bark_2x": "Dog Small Bark 2x", + "amzn_sfx_door_open": "Door Open", + "amzn_sfx_door_shut": "Door Shut", + "amzn_sfx_doorbell": "Doorbell", + "amzn_sfx_doorbell_buzz": "Doorbell Buzz", + "amzn_sfx_doorbell_chime": "Doorbell Chime", + "amzn_sfx_drinking_slurp": "Drinking Slurp", + "amzn_sfx_drum_and_cymbal": "Drum And Cymbal", + "amzn_sfx_drum_comedy": "Drum Comedy", + "amzn_sfx_earthquake_rumble": "Earthquake Rumble", + "amzn_sfx_electric_guitar": "Electric Guitar", + "amzn_sfx_electronic_beep": "Electronic Beep", + "amzn_sfx_electronic_major_chord": "Electronic Major Chord", + "amzn_sfx_elephant": "Elephant", + "amzn_sfx_elevator_bell_1x": "Elevator Bell 1x", + "amzn_sfx_elevator_open_bell": "Elevator Open Bell", + "amzn_sfx_fairy_melodic_chimes": "Fairy Melodic Chimes", + "amzn_sfx_fairy_sparkle_chimes": "Fairy Sparkle Chimes", + "amzn_sfx_faucet_drip": "Faucet Drip", + "amzn_sfx_faucet_running": "Faucet Running", + "amzn_sfx_fireplace_crackle": "Fireplace Crackle", + "amzn_sfx_fireworks": "Fireworks", + "amzn_sfx_fireworks_firecrackers": "Fireworks Firecrackers", + "amzn_sfx_fireworks_launch": "Fireworks Launch", + "amzn_sfx_fireworks_whistles": "Fireworks Whistles", + "amzn_sfx_food_frying": "Food Frying", + "amzn_sfx_footsteps": "Footsteps", + "amzn_sfx_footsteps_muffled": "Footsteps Muffled", + "amzn_sfx_ghost_spooky": "Ghost Spooky", + "amzn_sfx_glass_on_table": "Glass On Table", + "amzn_sfx_glasses_clink": "Glasses Clink", + "amzn_sfx_horse_gallop_4x": "Horse Gallop 4x", + "amzn_sfx_horse_huff_whinny": "Horse Huff Whinny", + "amzn_sfx_horse_neigh": "Horse Neigh", + "amzn_sfx_horse_neigh_low": "Horse Neigh Low", + "amzn_sfx_horse_whinny": "Horse Whinny", + "amzn_sfx_human_walking": "Human Walking", + "amzn_sfx_jar_on_table_1x": "Jar On Table 1x", + "amzn_sfx_kitchen_ambience": "Kitchen Ambience", + "amzn_sfx_large_crowd_cheer": "Large Crowd Cheer", + "amzn_sfx_large_fire_crackling": "Large Fire Crackling", + "amzn_sfx_laughter": "Laughter", + "amzn_sfx_laughter_giggle": "Laughter Giggle", + "amzn_sfx_lightning_strike": "Lightning Strike", + "amzn_sfx_lion_roar": "Lion Roar", + "amzn_sfx_magic_blast_1x": "Magic Blast 1x", + "amzn_sfx_monkey_calls_3x": "Monkey Calls 3x", + "amzn_sfx_monkey_chimp": "Monkey Chimp", + "amzn_sfx_monkeys_chatter": "Monkeys Chatter", + "amzn_sfx_motorcycle_accelerate": "Motorcycle Accelerate", + "amzn_sfx_motorcycle_engine_idle": "Motorcycle Engine Idle", + "amzn_sfx_motorcycle_engine_rev": "Motorcycle Engine Rev", + "amzn_sfx_musical_drone_intro": "Musical Drone Intro", + "amzn_sfx_oars_splashing_rowboat": "Oars Splashing Rowboat", + "amzn_sfx_object_on_table_2x": "Object On Table 2x", + "amzn_sfx_ocean_wave_1x": "Ocean Wave 1x", + "amzn_sfx_ocean_wave_on_rocks_1x": "Ocean Wave On Rocks 1x", + "amzn_sfx_ocean_wave_surf": "Ocean Wave Surf", + "amzn_sfx_people_walking": "People Walking", + "amzn_sfx_person_running": "Person Running", + "amzn_sfx_piano_note_1x": "Piano Note 1x", + "amzn_sfx_punch": "Punch", + "amzn_sfx_rain": "Rain", + "amzn_sfx_rain_on_roof": "Rain On Roof", + "amzn_sfx_rain_thunder": "Rain Thunder", + "amzn_sfx_rat_squeak_2x": "Rat Squeak 2x", + "amzn_sfx_rat_squeaks": "Rat Squeaks", + "amzn_sfx_raven_caw_1x": "Raven Caw 1x", + "amzn_sfx_raven_caw_2x": "Raven Caw 2x", + "amzn_sfx_restaurant_ambience": "Restaurant Ambience", + "amzn_sfx_rooster_crow": "Rooster Crow", + "amzn_sfx_scifi_air_escaping": "Scifi Air Escaping", + "amzn_sfx_scifi_alarm": "Scifi Alarm", + "amzn_sfx_scifi_alien_voice": "Scifi Alien Voice", + "amzn_sfx_scifi_boots_walking": "Scifi Boots Walking", + "amzn_sfx_scifi_close_large_explosion": "Scifi Close Large Explosion", + "amzn_sfx_scifi_door_open": "Scifi Door Open", + "amzn_sfx_scifi_engines_on": "Scifi Engines On", + "amzn_sfx_scifi_engines_on_large": "Scifi Engines On Large", + "amzn_sfx_scifi_engines_on_short_burst": "Scifi Engines On Short Burst", + "amzn_sfx_scifi_explosion": "Scifi Explosion", + "amzn_sfx_scifi_explosion_2x": "Scifi Explosion 2x", + "amzn_sfx_scifi_incoming_explosion": "Scifi Incoming Explosion", + "amzn_sfx_scifi_laser_gun_battle": "Scifi Laser Gun Battle", + "amzn_sfx_scifi_laser_gun_fires": "Scifi Laser Gun Fires", + "amzn_sfx_scifi_laser_gun_fires_large": "Scifi Laser Gun Fires Large", + "amzn_sfx_scifi_long_explosion_1x": "Scifi Long Explosion 1x", + "amzn_sfx_scifi_missile": "Scifi Missile", + "amzn_sfx_scifi_motor_short_1x": "Scifi Motor Short 1x", + "amzn_sfx_scifi_open_airlock": "Scifi Open Airlock", + "amzn_sfx_scifi_radar_high_ping": "Scifi Radar High Ping", + "amzn_sfx_scifi_radar_low": "Scifi Radar Low", + "amzn_sfx_scifi_radar_medium": "Scifi Radar Medium", + "amzn_sfx_scifi_run_away": "Scifi Run Away", + "amzn_sfx_scifi_sheilds_up": "Scifi Sheilds Up", + "amzn_sfx_scifi_short_low_explosion": "Scifi Short Low Explosion", + "amzn_sfx_scifi_small_whoosh_flyby": "Scifi Small Whoosh Flyby", + "amzn_sfx_scifi_small_zoom_flyby": "Scifi Small Zoom Flyby", + "amzn_sfx_scifi_sonar_ping_3x": "Scifi Sonar Ping 3x", + "amzn_sfx_scifi_sonar_ping_4x": "Scifi Sonar Ping 4x", + "amzn_sfx_scifi_spaceship_flyby": "Scifi Spaceship Flyby", + "amzn_sfx_scifi_timer_beep": "Scifi Timer Beep", + "amzn_sfx_scifi_zap_backwards": "Scifi Zap Backwards", + "amzn_sfx_scifi_zap_electric": "Scifi Zap Electric", + "amzn_sfx_sheep_baa": "Sheep Baa", + "amzn_sfx_sheep_bleat": "Sheep Bleat", + "amzn_sfx_silverware_clank": "Silverware Clank", + "amzn_sfx_sirens": "Sirens", + "amzn_sfx_sleigh_bells": "Sleigh Bells", + "amzn_sfx_small_stream": "Small Stream", + "amzn_sfx_sneeze": "Sneeze", + "amzn_sfx_stream": "Stream", + "amzn_sfx_strong_wind_desert": "Strong Wind Desert", + "amzn_sfx_strong_wind_whistling": "Strong Wind Whistling", + "amzn_sfx_subway_leaving": "Subway Leaving", + "amzn_sfx_subway_passing": "Subway Passing", + "amzn_sfx_subway_stopping": "Subway Stopping", + "amzn_sfx_swoosh_cartoon_fast": "Swoosh Cartoon Fast", + "amzn_sfx_swoosh_fast_1x": "Swoosh Fast 1x", + "amzn_sfx_swoosh_fast_6x": "Swoosh Fast 6x", + "amzn_sfx_test_tone": "Test Tone", + "amzn_sfx_thunder_rumble": "Thunder Rumble", + "amzn_sfx_toilet_flush": "Toilet Flush", + "amzn_sfx_trumpet_bugle": "Trumpet Bugle", + "amzn_sfx_turkey_gobbling": "Turkey Gobbling", + "amzn_sfx_typing_medium": "Typing Medium", + "amzn_sfx_typing_short": "Typing Short", + "amzn_sfx_typing_typewriter": "Typing Typewriter", + "amzn_sfx_vacuum_off": "Vacuum Off", + "amzn_sfx_vacuum_on": "Vacuum On", + "amzn_sfx_walking_in_mud": "Walking In Mud", + "amzn_sfx_walking_in_snow": "Walking In Snow", + "amzn_sfx_walking_on_grass": "Walking On Grass", + "amzn_sfx_water_dripping": "Water Dripping", + "amzn_sfx_water_droplets": "Water Droplets", + "amzn_sfx_wind_strong_gusting": "Wind Strong Gusting", + "amzn_sfx_wind_whistling_desert": "Wind Whistling Desert", + "amzn_sfx_wings_flap_4x": "Wings Flap 4x", + "amzn_sfx_wings_flap_fast": "Wings Flap Fast", + "amzn_sfx_wolf_howl": "Wolf Howl", + "amzn_sfx_wolf_young_howl": "Wolf Young Howl", + "amzn_sfx_wooden_door": "Wooden Door", + "amzn_sfx_wooden_door_creaks_long": "Wooden Door Creaks Long", + "amzn_sfx_wooden_door_creaks_multiple": "Wooden Door Creaks Multiple", + "amzn_sfx_wooden_door_creaks_open": "Wooden Door Creaks Open", + "amzn_ui_sfx_gameshow_bridge": "Gameshow Bridge", + "amzn_ui_sfx_gameshow_countdown_loop_32s_full": "Gameshow Countdown Loop 32s Full", + "amzn_ui_sfx_gameshow_countdown_loop_64s_full": "Gameshow Countdown Loop 64s Full", + "amzn_ui_sfx_gameshow_countdown_loop_64s_minimal": "Gameshow Countdown Loop 64s Minimal", + "amzn_ui_sfx_gameshow_intro": "Gameshow Intro", + "amzn_ui_sfx_gameshow_negative_response": "Gameshow Negative Response", + "amzn_ui_sfx_gameshow_neutral_response": "Gameshow Neutral Response", + "amzn_ui_sfx_gameshow_outro": "Gameshow Outro", + "amzn_ui_sfx_gameshow_player1": "Gameshow Player1", + "amzn_ui_sfx_gameshow_player2": "Gameshow Player2", + "amzn_ui_sfx_gameshow_player3": "Gameshow Player3", + "amzn_ui_sfx_gameshow_player4": "Gameshow Player4", + "amzn_ui_sfx_gameshow_positive_response": "Gameshow Positive Response", + "amzn_ui_sfx_gameshow_tally_negative": "Gameshow Tally Negative", + "amzn_ui_sfx_gameshow_tally_positive": "Gameshow Tally Positive", + "amzn_ui_sfx_gameshow_waiting_loop_30s": "Gameshow Waiting Loop 30s", + "anchor": "Anchor", + "answering_machines": "Answering Machines", + "arcs_sparks": "Arcs Sparks", + "arrows_bows": "Arrows Bows", + "baby": "Baby", + "back_up_beeps": "Back Up Beeps", + "bars_restaurants": "Bars Restaurants", + "baseball": "Baseball", + "basketball": "Basketball", + "battles": "Battles", + "beeps_tones": "Beeps Tones", + "bell": "Bell", + "bikes": "Bikes", + "billiards": "Billiards", + "board_games": "Board Games", + "body": "Body", + "boing": "Boing", + "books": "Books", + "bow_wash": "Bow Wash", + "box": "Box", + "break_shatter_smash": "Break Shatter Smash", + "breaks": "Breaks", + "brooms_mops": "Brooms Mops", + "bullets": "Bullets", + "buses": "Buses", + "buzz": "Buzz", + "buzz_hums": "Buzz Hums", + "buzzers": "Buzzers", + "buzzers_pistols": "Buzzers Pistols", + "cables_metal": "Cables Metal", + "camera": "Camera", + "cannons": "Cannons", + "car_alarm": "Car Alarm", + "car_alarms": "Car Alarms", + "car_cell_phones": "Car Cell Phones", + "carnivals_fairs": "Carnivals Fairs", + "cars": "Cars", + "casino": "Casino", + "casinos": "Casinos", + "cellar": "Cellar", + "chimes": "Chimes", + "chimes_bells": "Chimes Bells", + "chorus": "Chorus", + "christmas": "Christmas", + "church_bells": "Church Bells", + "clock": "Clock", + "cloth": "Cloth", + "concrete": "Concrete", + "construction": "Construction", + "construction_factory": "Construction Factory", + "crashes": "Crashes", + "crowds": "Crowds", + "debris": "Debris", + "dining_kitchens": "Dining Kitchens", + "dinosaurs": "Dinosaurs", + "dripping": "Dripping", + "drops": "Drops", + "electric": "Electric", + "electrical": "Electrical", + "elevator": "Elevator", + "evolution_monsters": "Evolution Monsters", + "explosions": "Explosions", + "factory": "Factory", + "falls": "Falls", + "fax_scanner_copier": "Fax Scanner Copier", + "feedback_mics": "Feedback Mics", + "fight": "Fight", + "fire": "Fire", + "fire_extinguisher": "Fire Extinguisher", + "fireballs": "Fireballs", + "fireworks": "Fireworks", + "fishing_pole": "Fishing Pole", + "flags": "Flags", + "football": "Football", + "footsteps": "Footsteps", + "futuristic": "Futuristic", + "futuristic_ship": "Futuristic Ship", + "gameshow": "Gameshow", + "gear": "Gear", + "ghosts_demons": "Ghosts Demons", + "giant_monster": "Giant Monster", + "glass": "Glass", + "glasses_clink": "Glasses Clink", + "golf": "Golf", + "gorilla": "Gorilla", + "grenade_lanucher": "Grenade Lanucher", + "griffen": "Griffen", + "gyms_locker_rooms": "Gyms Locker Rooms", + "handgun_loading": "Handgun Loading", + "handgun_shot": "Handgun Shot", + "handle": "Handle", + "hands": "Hands", + "heartbeats_ekg": "Heartbeats EKG", + "helicopter": "Helicopter", + "high_tech": "High Tech", + "hit_punch_slap": "Hit Punch Slap", + "hits": "Hits", + "horns": "Horns", + "horror": "Horror", + "hot_tub_filling_up": "Hot Tub Filling Up", + "human": "Human", + "human_vocals": "Human Vocals", + "hygene": "Hygene", + "ice_skating": "Ice Skating", + "ignitions": "Ignitions", + "infantry": "Infantry", + "intro": "Intro", + "jet": "Jet", + "juggling": "Juggling", + "key_lock": "Key Lock", + "kids": "Kids", + "knocks": "Knocks", + "lab_equip": "Lab Equip", + "lacrosse": "Lacrosse", + "lamps_lanterns": "Lamps Lanterns", + "leather": "Leather", + "liquid_suction": "Liquid Suction", + "locker_doors": "Locker Doors", + "machine_gun": "Machine Gun", + "magic_spells": "Magic Spells", + "medium_large_explosions": "Medium Large Explosions", + "metal": "Metal", + "modern_rings": "Modern Rings", + "money_coins": "Money Coins", + "motorcycles": "Motorcycles", + "movement": "Movement", + "moves": "Moves", + "nature": "Nature", + "oar_boat": "Oar Boat", + "pagers": "Pagers", + "paintball": "Paintball", + "paper": "Paper", + "parachute": "Parachute", + "pay_phones": "Pay Phones", + "phone_beeps": "Phone Beeps", + "pigmy_bats": "Pigmy Bats", + "pills": "Pills", + "pour_water": "Pour Water", + "power_up_down": "Power Up Down", + "printers": "Printers", + "prison": "Prison", + "public_space": "Public Space", + "racquetball": "Racquetball", + "radios_static": "Radios Static", + "rain": "Rain", + "rc_airplane": "RC Airplane", + "rc_car": "RC Car", + "refrigerators_freezers": "Refrigerators Freezers", + "regular": "Regular", + "respirator": "Respirator", + "rifle": "Rifle", + "roller_coaster": "Roller Coaster", + "rollerskates_rollerblades": "RollerSkates RollerBlades", + "room_tones": "Room Tones", + "ropes_climbing": "Ropes Climbing", + "rotary_rings": "Rotary Rings", + "rowboat_canoe": "Rowboat Canoe", + "rubber": "Rubber", + "running": "Running", + "sails": "Sails", + "sand_gravel": "Sand Gravel", + "screen_doors": "Screen Doors", + "screens": "Screens", + "seats_stools": "Seats Stools", + "servos": "Servos", + "shoes_boots": "Shoes Boots", + "shotgun": "Shotgun", + "shower": "Shower", + "sink_faucet": "Sink Faucet", + "sink_filling_water": "Sink Filling Water", + "sink_run_and_off": "Sink Run And Off", + "sink_water_splatter": "Sink Water Splatter", + "sirens": "Sirens", + "skateboards": "Skateboards", + "ski": "Ski", + "skids_tires": "Skids Tires", + "sled": "Sled", + "slides": "Slides", + "small_explosions": "Small Explosions", + "snow": "Snow", + "snowmobile": "Snowmobile", + "soldiers": "Soldiers", + "splash_water": "Splash Water", + "splashes_sprays": "Splashes Sprays", + "sports_whistles": "Sports Whistles", + "squeaks": "Squeaks", + "squeaky": "Squeaky", + "stairs": "Stairs", + "steam": "Steam", + "submarine_diesel": "Submarine Diesel", + "swing_doors": "Swing Doors", + "switches_levers": "Switches Levers", + "swords": "Swords", + "tape": "Tape", + "tape_machine": "Tape Machine", + "televisions_shows": "Televisions Shows", + "tennis_pingpong": "Tennis PingPong", + "textile": "Textile", + "throw": "Throw", + "thunder": "Thunder", + "ticks": "Ticks", + "timer": "Timer", + "toilet_flush": "Toilet Flush", + "tone": "Tone", + "tones_noises": "Tones Noises", + "toys": "Toys", + "tractors": "Tractors", + "traffic": "Traffic", + "train": "Train", + "trucks_vans": "Trucks Vans", + "turnstiles": "Turnstiles", + "typing": "Typing", + "umbrella": "Umbrella", + "underwater": "Underwater", + "vampires": "Vampires", + "various": "Various", + "video_tunes": "Video Tunes", + "volcano_earthquake": "Volcano Earthquake", + "watches": "Watches", + "water": "Water", + "water_running": "Water Running", + "werewolves": "Werewolves", + "winches_gears": "Winches Gears", + "wind": "Wind", + "wood": "Wood", + "wood_boat": "Wood Boat", + "woosh": "Woosh", + "zap": "Zap", + "zippers": "Zippers" + } + } + }, + "exceptions": { + "cannot_connect_with_error": { + "message": "Error connecting: {error}" + }, + "cannot_retrieve_data_with_error": { + "message": "Error retrieving data: {error}" + }, + "device_serial_number_missing": { + "message": "Device serial number missing: {device_id}" + }, + "invalid_device_id": { + "message": "Invalid device ID specified: {device_id}" + }, + "invalid_sound_value": { + "message": "Invalid sound {sound} with variant {variant} specified" + }, + "entry_not_loaded": { + "message": "Entry not loaded: {entry}" + } } } diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index b8f78134feb..e53ea40965a 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): entity_description: AmazonSwitchEntityDescription + @alexa_api_call async def _switch_set_state(self, state: bool) -> None: """Set desired switch state.""" method = getattr(self.coordinator.api, self.entity_description.method) diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py new file mode 100644 index 00000000000..437b681413b --- /dev/null +++ b/homeassistant/components/alexa_devices/utils.py @@ -0,0 +1,40 @@ +"""Utils for Alexa Devices.""" + +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import AmazonEntity + + +def alexa_api_call[_T: AmazonEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch Alexa API call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except CannotConnect as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + + return cmd_wrapper diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index 9eab6f42ad3..06641327946 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -2,11 +2,22 @@ import amberelectric +from homeassistant.components.sensor import ConfigType from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv -from .const import CONF_SITE_ID, PLATFORMS +from .const import CONF_SITE_ID, DOMAIN, PLATFORMS from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .services import setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Amber component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index c25258e2e33..b5f034b4448 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN +from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN, REQUEST_TIMEOUT API_URL = "https://app.amber.com.au/developers" @@ -64,7 +64,9 @@ class AmberElectricConfigFlow(ConfigFlow, domain=DOMAIN): api = amberelectric.AmberApi(api_client) try: - sites: list[Site] = filter_sites(api.get_sites()) + sites: list[Site] = filter_sites( + api.get_sites(_request_timeout=REQUEST_TIMEOUT) + ) except amberelectric.ApiException as api_exception: if api_exception.status == 403: self._errors[CONF_API_TOKEN] = "invalid_api_token" diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 56324628ed6..3a1dbc9023a 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -1,14 +1,25 @@ """Amber Electric Constants.""" import logging +from typing import Final from homeassistant.const import Platform -DOMAIN = "amberelectric" +DOMAIN: Final = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" +ATTR_CHANNEL_TYPE = "channel_type" + ATTRIBUTION = "Data provided by Amber Electric" 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" + +REQUEST_TIMEOUT = 15 diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 1edf64ba0d6..2ea14b5200b 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -10,14 +10,14 @@ from amberelectric.models.actual_interval import ActualInterval from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.rest import ApiException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER +from .const import LOGGER, REQUEST_TIMEOUT +from .helpers import normalize_descriptor type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] @@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> return interval.channel_type == ChannelType.FEEDIN -def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: - """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" - if descriptor is None: - return None - if descriptor.value == "spike": - return "spike" - if descriptor.value == "high": - return "high" - if descriptor.value == "neutral": - return "neutral" - if descriptor.value == "low": - return "low" - if descriptor.value == "veryLow": - return "very_low" - if descriptor.value == "extremelyLow": - return "extremely_low" - if descriptor.value == "negative": - return "negative" - return None - - class AmberUpdateCoordinator(DataUpdateCoordinator): """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" @@ -103,7 +82,11 @@ class AmberUpdateCoordinator(DataUpdateCoordinator): "grid": {}, } try: - data = self._api.get_current_prices(self.site_id, next=48) + data = self._api.get_current_prices( + self.site_id, + next=288, + _request_timeout=REQUEST_TIMEOUT, + ) intervals = [interval.actual_instance for interval in data] except ApiException as api_exception: raise UpdateFailed("Missing price data, skipping update") from api_exception diff --git a/homeassistant/components/amberelectric/helpers.py b/homeassistant/components/amberelectric/helpers.py new file mode 100644 index 00000000000..c383c21f276 --- /dev/null +++ b/homeassistant/components/amberelectric/helpers.py @@ -0,0 +1,25 @@ +"""Formatting helpers used to convert things.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +DESCRIPTOR_MAP: dict[str, str] = { + PriceDescriptor.SPIKE: "spike", + PriceDescriptor.HIGH: "high", + PriceDescriptor.NEUTRAL: "neutral", + PriceDescriptor.LOW: "low", + PriceDescriptor.VERYLOW: "very_low", + PriceDescriptor.EXTREMELYLOW: "extremely_low", + PriceDescriptor.NEGATIVE: "negative", +} + + +def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: + """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" + if descriptor in DESCRIPTOR_MAP: + return DESCRIPTOR_MAP[descriptor] + return None + + +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) diff --git a/homeassistant/components/amberelectric/icons.json b/homeassistant/components/amberelectric/icons.json index 7dd6ae3217c..a2d0a0a5486 100644 --- a/homeassistant/components/amberelectric/icons.json +++ b/homeassistant/components/amberelectric/icons.json @@ -22,5 +22,10 @@ } } } + }, + "services": { + "get_forecasts": { + "service": "mdi:transmission-tower" + } } } diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 7276ddb26a5..f7a61bea5a5 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION -from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .helpers import format_cents_to_dollars, normalize_descriptor UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" -def format_cents_to_dollars(cents: float) -> float: - """Return a formatted conversion from cents to dollars.""" - return round(cents / 100, 2) - - def friendly_channel_type(channel_type: str) -> str: """Return a human readable version of the channel type.""" if channel_type == "controlled_load": diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py new file mode 100644 index 00000000000..c22a04f2845 --- /dev/null +++ b/homeassistant/components/amberelectric/services.py @@ -0,0 +1,121 @@ +"""Amber Electric Service class.""" + +from amberelectric.models.channel import ChannelType +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util.json import JsonValueType + +from .const import ( + ATTR_CHANNEL_TYPE, + CONTROLLED_LOAD_CHANNEL, + DOMAIN, + FEED_IN_CHANNEL, + GENERAL_CHANNEL, + SERVICE_GET_FORECASTS, +) +from .coordinator import AmberConfigEntry +from .helpers import format_cents_to_dollars, normalize_descriptor + +GET_FORECASTS_SCHEMA = vol.Schema( + { + ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}), + ATTR_CHANNEL_TYPE: vol.In( + [GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL] + ), + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry: + """Get the Amber config entry.""" + if not (entry := 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 entry + + +def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]: + """Return an array of forecasts.""" + results: list[JsonValueType] = [] + + if channel_type not in data["forecasts"]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="channel_not_found", + translation_placeholders={"channel_type": channel_type}, + ) + + intervals = data["forecasts"][channel_type] + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.var_date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEEDIN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + datum["descriptor"] = normalize_descriptor(interval.descriptor) + + if interval.range is not None: + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) + + if interval.advanced_price is not None: + multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1 + datum["advanced_price_low"] = multiplier * format_cents_to_dollars( + interval.advanced_price.low + ) + datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars( + interval.advanced_price.predicted + ) + datum["advanced_price_high"] = multiplier * format_cents_to_dollars( + interval.advanced_price.high + ) + + results.append(datum) + + return results + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Amber integration.""" + + async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse: + channel_type = call.data[ATTR_CHANNEL_TYPE] + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + coordinator = entry.runtime_data + forecasts = get_forecasts(channel_type, coordinator.data) + return {"forecasts": forecasts} + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECASTS, + handle_get_forecasts, + GET_FORECASTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/amberelectric/services.yaml b/homeassistant/components/amberelectric/services.yaml new file mode 100644 index 00000000000..89a7027fee0 --- /dev/null +++ b/homeassistant/components/amberelectric/services.yaml @@ -0,0 +1,16 @@ +get_forecasts: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: amberelectric + channel_type: + required: true + selector: + select: + options: + - general + - controlled_load + - feed_in + translation_key: channel_type diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json index 684a5a2a0cc..f9eba4a1f27 100644 --- a/homeassistant/components/amberelectric/strings.json +++ b/homeassistant/components/amberelectric/strings.json @@ -1,25 +1,61 @@ { "config": { + "error": { + "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", + "no_site": "No site provided", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, "step": { + "site": { + "data": { + "site_id": "Site NMI", + "site_name": "Site name" + }, + "description": "Select the NMI of the site you would like to add" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", "site_id": "Site ID" }, "description": "Go to {api_url} to generate an API key" - }, - "site": { - "data": { - "site_id": "Site NMI", - "site_name": "Site Name" - }, - "description": "Select the NMI of the site you would like to add" } + } + }, + "services": { + "get_forecasts": { + "name": "Get price forecasts", + "description": "Retrieves price forecasts from Amber Electric for a site.", + "fields": { + "config_entry_id": { + "description": "The config entry of the site to get forecasts for.", + "name": "Config entry" + }, + "channel_type": { + "name": "Channel type", + "description": "The channel to get forecasts for." + } + } + } + }, + "exceptions": { + "integration_not_found": { + "message": "Config entry \"{target}\" not found in registry." }, - "error": { - "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", - "no_site": "No site provided", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "not_loaded": { + "message": "{target} is not loaded." + }, + "channel_not_found": { + "message": "There is no {channel_type} channel at this site." + } + }, + "selector": { + "channel_type": { + "options": { + "general": "General", + "controlled_load": "Controlled load", + "feed_in": "Feed-in" + } } } } diff --git a/homeassistant/components/ambient_station/entity.py b/homeassistant/components/ambient_station/entity.py index 24dfab438d8..9dec905b157 100644 --- a/homeassistant/components/ambient_station/entity.py +++ b/homeassistant/components/ambient_station/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from aioambient.util import get_public_device_id from homeassistant.core import callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription @@ -37,6 +37,7 @@ class AmbientWeatherEntity(Entity): identifiers={(DOMAIN, mac_address)}, manufacturer="Ambient Weather", name=station_name.capitalize(), + connections={(CONNECTION_NETWORK_MAC, mac_address)}, ) self._attr_unique_id = f"{mac_address}_{description.key}" diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 7d8f8f9e6c8..85e37b0df64 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["amcrest"], "quality_scale": "legacy", - "requirements": ["amcrest==1.9.8"] + "requirements": ["amcrest==1.9.9"] } diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 0df3b8138e2..83610f0dc75 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +from .http import AnalyticsDevicesView CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -55,6 +56,8 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_analytics) websocket_api.async_register_command(hass, websocket_analytics_preferences) + hass.http.register_view(AnalyticsDevicesView) + hass.data[DATA_COMPONENT] = analytics return True diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 1a07a8abd0f..8b276021d38 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -27,7 +27,7 @@ from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.storage import Store @@ -77,6 +77,11 @@ from .const import ( ) +def gen_uuid() -> str: + """Generate a new UUID.""" + return uuid.uuid4().hex + + @dataclass class AnalyticsData: """Analytics data.""" @@ -184,7 +189,7 @@ class Analytics: return if self._data.uuid is None: - self._data.uuid = uuid.uuid4().hex + self._data.uuid = gen_uuid() await self._store.async_save(dataclass_asdict(self._data)) if self.supervisor: @@ -381,3 +386,68 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: ).values(): domains.update(platforms) return domains + + +async def async_devices_payload(hass: HomeAssistant) -> dict: + """Return the devices payload.""" + devices: list[dict[str, Any]] = [] + dev_reg = dr.async_get(hass) + # Devices that need via device info set + new_indexes: dict[str, int] = {} + via_devices: dict[str, str] = {} + + seen_integrations = set() + + for device in dev_reg.devices.values(): + if not device.primary_config_entry: + continue + + config_entry = hass.config_entries.async_get_entry(device.primary_config_entry) + + if config_entry is None: + continue + + seen_integrations.add(config_entry.domain) + + new_indexes[device.id] = len(devices) + devices.append( + { + "integration": config_entry.domain, + "manufacturer": device.manufacturer, + "model_id": device.model_id, + "model": device.model, + "sw_version": device.sw_version, + "hw_version": device.hw_version, + "has_configuration_url": device.configuration_url is not None, + "via_device": None, + "entry_type": device.entry_type.value if device.entry_type else None, + } + ) + + if device.via_device_id: + via_devices[device.id] = device.via_device_id + + for from_device, via_device in via_devices.items(): + if via_device not in new_indexes: + continue + devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device] + + integrations = { + domain: integration + for domain, integration in ( + await async_get_integrations(hass, seen_integrations) + ).items() + if isinstance(integration, Integration) + } + + for device_info in devices: + if integration := integrations.get(device_info["integration"]): + device_info["is_custom_integration"] = not integration.is_built_in + # Include version for custom integrations + if not integration.is_built_in and integration.version: + device_info["custom_integration_version"] = str(integration.version) + + return { + "version": "home-assistant:1", + "devices": devices, + } diff --git a/homeassistant/components/analytics/http.py b/homeassistant/components/analytics/http.py new file mode 100644 index 00000000000..a91b373bc45 --- /dev/null +++ b/homeassistant/components/analytics/http.py @@ -0,0 +1,27 @@ +"""HTTP endpoints for analytics integration.""" + +from aiohttp import web + +from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin +from homeassistant.core import HomeAssistant + +from .analytics import async_devices_payload + + +class AnalyticsDevicesView(HomeAssistantView): + """View to handle analytics devices payload download requests.""" + + url = "/api/analytics/devices" + name = "api:analytics:devices" + + @require_admin + async def get(self, request: web.Request) -> web.Response: + """Return analytics devices payload as JSON.""" + hass: HomeAssistant = request.app[KEY_HASS] + payload = await async_devices_payload(hass) + return self.json( + payload, + headers={ + "Content-Disposition": "attachment; filename=analytics_devices.json" + }, + ) diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 5142a86ad97..ab51ed31c9e 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -3,7 +3,7 @@ "name": "Analytics", "after_dependencies": ["energy", "hassio", "recorder"], "codeowners": ["@home-assistant/core", "@ludeeus"], - "dependencies": ["api", "websocket_api"], + "dependencies": ["api", "websocket_api", "http"], "documentation": "https://www.home-assistant.io/integrations/analytics", "integration_type": "system", "iot_class": "cloud_push", diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index ee7f6611c65..2d66d5149cf 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -55,7 +55,6 @@ async def async_setup_entry( entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -65,10 +64,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index b2648f7c13c..d5c0c4a7f73 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -11,7 +11,11 @@ from python_homeassistant_analytics import ( from python_homeassistant_analytics.models import Environment, IntegrationType import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -129,7 +133,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload): """Handle Homeassistant Analytics options.""" async def async_step_init( diff --git a/homeassistant/components/android_ip_webcam/coordinator.py b/homeassistant/components/android_ip_webcam/coordinator.py index c72d6ae1177..ec701cdf7d3 100644 --- a/homeassistant/components/android_ip_webcam/coordinator.py +++ b/homeassistant/components/android_ip_webcam/coordinator.py @@ -30,10 +30,9 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]): cam: PyDroidIPCam, ) -> None: """Initialize the Android IP Webcam.""" - self.hass = hass self.cam = cam super().__init__( - self.hass, + hass, _LOGGER, config_entry=config_entry, name=f"{DOMAIN} {config_entry.data[CONF_HOST]}", diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index c9e62908cac..6a60d84e39e 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -56,7 +56,7 @@ SERVICE_UPLOAD = "upload" ANDROIDTV_STATES = { "off": MediaPlayerState.OFF, "idle": MediaPlayerState.IDLE, - "standby": MediaPlayerState.STANDBY, + "standby": MediaPlayerState.IDLE, "playing": MediaPlayerState.PLAYING, "paused": MediaPlayerState.PAUSED, } diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 28a372da4ea..328ac863e46 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -5,26 +5,18 @@ from __future__ import annotations from asyncio import timeout import logging -from androidtvremote2 import ( - AndroidTVRemote, - CannotConnect, - ConnectionClosed, - InvalidAuth, -) +from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .helpers import create_api, get_enable_ime +from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] -AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote] - async def async_setup_entry( hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry @@ -76,21 +68,14 @@ async def async_setup_entry( entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(api.disconnect) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry +) -> bool: """Unload a config entry.""" _LOGGER.debug("async_unload_entry: %s", entry.data) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - _LOGGER.debug( - "async_update_options: data: %s options: %s", entry.data, entry.options - ) - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 78f24fc498c..0a236c7c9ef 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -16,10 +16,10 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback @@ -33,7 +33,7 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN -from .helpers import create_api, get_enable_ime +from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime _LOGGER = logging.getLogger(__name__) @@ -41,12 +41,6 @@ APPS_NEW_ID = "NewApp" CONF_APP_DELETE = "app_delete" CONF_APP_ID = "app_id" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required("host"): str, - } -) - STEP_PAIR_DATA_SCHEMA = vol.Schema( { vol.Required("pin"): str, @@ -67,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the initial and reconfigure step.""" errors: dict[str, str] = {} if user_input is not None: self.host = user_input[CONF_HOST] @@ -76,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): await api.async_generate_cert_if_missing() self.name, self.mac = await api.async_get_name_and_mac() await self.async_set_unique_id(format_mac(self.mac)) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) return await self._async_start_pair() except (CannotConnect, ConnectionClosed): # Likely invalid IP address or device is network unreachable. Stay # in the user step allowing the user to enter a different host. errors["base"] = "cannot_connect" + else: + user_input = {} + default_host = user_input.get(CONF_HOST, vol.UNDEFINED) + if self.source == SOURCE_RECONFIGURE: + default_host = self._get_reconfigure_entry().data[CONF_HOST] return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, + step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user", + data_schema=vol.Schema( + {vol.Required(CONF_HOST, default=default_host): str} + ), errors=errors, ) @@ -105,10 +116,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): pin = user_input["pin"] await self.api.async_finish_pairing(pin) if self.source == SOURCE_REAUTH: - await self.hass.config_entries.async_reload( - self._get_reauth_entry().entry_id + return self.async_update_reload_and_abort( + self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True ) - return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=self.name, data={ @@ -217,19 +228,25 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user(user_input) + @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: AndroidTVRemoteConfigEntry, ) -> AndroidTVRemoteOptionsFlowHandler: """Create the options flow.""" return AndroidTVRemoteOptionsFlowHandler(config_entry) -class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): +class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload): """Android TV Remote options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None: """Initialize options flow.""" self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) self._conf_app_id: str | None = None diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py index 41595451be8..add28b807e9 100644 --- a/homeassistant/components/androidtv_remote/diagnostics.py +++ b/homeassistant/components/androidtv_remote/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant -from . import AndroidTVRemoteConfigEntry +from .helpers import AndroidTVRemoteConfigEntry TO_REDACT = {CONF_HOST, CONF_MAC} diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index bf146a11e13..7a1e2d6bf06 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -6,7 +6,6 @@ from typing import Any from androidtvremote2 import AndroidTVRemote, ConnectionClosed -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -14,6 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity import Entity from .const import CONF_APPS, DOMAIN +from .helpers import AndroidTVRemoteConfigEntry class AndroidTVRemoteBaseEntity(Entity): @@ -23,7 +23,9 @@ class AndroidTVRemoteBaseEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + def __init__( + self, api: AndroidTVRemote, config_entry: AndroidTVRemoteConfigEntry + ) -> None: """Initialize the entity.""" self._api = api self._host = config_entry.data[CONF_HOST] diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index cdd67b029fc..9052a414393 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -10,6 +10,8 @@ from homeassistant.helpers.storage import STORAGE_DIR from .const import CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE +AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote] + def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote: """Create an AndroidTVRemote instance.""" @@ -23,6 +25,6 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem ) -def get_enable_ime(entry: ConfigEntry) -> bool: +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) + return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return] diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 7896f7eefc8..9f41d8230c6 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.2.2"], + "requirements": ["androidtvremote2==0.2.3"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 5bc205b32df..e4f653cbcf1 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from typing import Any -from androidtvremote2 import AndroidTVRemote, ConnectionClosed +from androidtvremote2 import AndroidTVRemote, ConnectionClosed, VolumeInfo from homeassistant.components.media_player import ( BrowseMedia, @@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import AndroidTVRemoteConfigEntry from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity +from .helpers import AndroidTVRemoteConfigEntry PARALLEL_UPDATES = 0 @@ -75,13 +75,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt else current_app ) - def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None: + def _update_volume_info(self, volume_info: VolumeInfo) -> None: """Update volume info.""" if volume_info.get("max"): - self._attr_volume_level = int(volume_info["level"]) / int( - volume_info["max"] - ) - self._attr_is_volume_muted = bool(volume_info["muted"]) + self._attr_volume_level = volume_info["level"] / volume_info["max"] + self._attr_is_volume_muted = volume_info["muted"] else: self._attr_volume_level = None self._attr_is_volume_muted = None @@ -93,7 +91,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt self.async_write_ha_state() @callback - def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None: + def _volume_info_updated(self, volume_info: VolumeInfo) -> None: """Update the state when the volume info changes.""" self._update_volume_info(volume_info) self.async_write_ha_state() @@ -102,8 +100,10 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt """Register callbacks.""" await super().async_added_to_hass() - self._update_current_app(self._api.current_app) - self._update_volume_info(self._api.volume_info) + if self._api.current_app is not None: + self._update_current_app(self._api.current_app) + if self._api.volume_info is not None: + self._update_volume_info(self._api.volume_info) self._api.add_current_app_updated_callback(self._current_app_updated) self._api.add_volume_info_updated_callback(self._volume_info_updated) diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 212b0491d2d..612d27de189 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -20,9 +20,9 @@ from homeassistant.components.remote import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import AndroidTVRemoteConfigEntry from .const import CONF_APP_NAME from .entity import AndroidTVRemoteBaseEntity +from .helpers import AndroidTVRemoteConfigEntry PARALLEL_UPDATES = 0 @@ -63,7 +63,8 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): self._attr_activity_list = [ app.get(CONF_APP_NAME, "") for app in self._apps.values() ] - self._update_current_app(self._api.current_app) + if self._api.current_app is not None: + self._update_current_app(self._api.current_app) self._api.add_current_app_updated_callback(self._current_app_updated) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index c82b815e27a..d0eb1d0dca4 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -6,6 +6,18 @@ "description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Android TV device." + } + }, + "reconfigure": { + "description": "Update the IP address of this previously configured Android TV device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Android TV device." } }, "zeroconf_confirm": { @@ -16,6 +28,9 @@ "description": "Enter the pairing code displayed on the Android TV ({name}).", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "Pairing code displayed on the Android TV device." } }, "reauth_confirm": { @@ -32,7 +47,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": "Please ensure you reconfigure against the same device." } }, "options": { @@ -40,7 +57,11 @@ "init": { "data": { "apps": "Configure applications list", - "enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard." + "enable_ime": "Enable IME" + }, + "data_description": { + "apps": "Here you can define the list of applications, specify names and icons that will be displayed in the UI.", + "enable_ime": "Enable this option to be able to get the current app name and send text as keyboard input. Disable it for devices that show 'Use keyboard on mobile device screen' instead of the on-screen keyboard." } }, "apps": { @@ -53,8 +74,10 @@ "app_delete": "Check to delete this application" }, "data_description": { + "app_name": "Name of the application as you would like it to be displayed in Home Assistant.", "app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android", - "app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename" + "app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename", + "app_delete": "Check this box to delete the application from the list." } } } diff --git a/homeassistant/components/anthemav/strings.json b/homeassistant/components/anthemav/strings.json index 15e365b3e63..774785f9d29 100644 --- a/homeassistant/components/anthemav/strings.json +++ b/homeassistant/components/anthemav/strings.json @@ -10,7 +10,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on" + "cannot_receive_deviceinfo": "Failed to retrieve MAC address. Make sure the device is turned on" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index c13c82f0020..b996b7d38c5 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -17,7 +17,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL +from .const import ( + CONF_CHAT_MODEL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, +) PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -55,6 +61,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -63,14 +71,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_update_options( + hass: HomeAssistant, entry: AnthropicConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -84,30 +103,61 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -117,12 +167,81 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_CONVERSATION_NAME, options={}, version=2, + minor_version=3, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + if entry.version == 2 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 6a18cb693cd..0c555d19bd9 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -75,6 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index d7e10dd7af2..356140ff66e 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -10,9 +10,9 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation" CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "claude-3-haiku-20240307" +RECOMMENDED_CHAT_MODEL = "claude-3-5-haiku-latest" CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 1024 +RECOMMENDED_MAX_TOKENS = 3000 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_THINKING_BUDGET = "thinking_budget" @@ -20,10 +20,8 @@ RECOMMENDED_THINKING_BUDGET = 0 MIN_THINKING_BUDGET = 1024 THINKING_MODELS = [ - "claude-3-7-sonnet-20250219", - "claude-3-7-sonnet-latest", - "claude-opus-4-20250514", - "claude-opus-4-0", - "claude-sonnet-4-20250514", + "claude-3-7-sonnet", "claude-sonnet-4-0", + "claude-opus-4-0", + "claude-opus-4-1", ] diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index f34d9ed97b6..4eb40974b7a 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,69 +1,16 @@ """Conversation support for Anthropic.""" -from collections.abc import AsyncGenerator, Callable, Iterable -import json -from typing import Any, Literal, cast - -import anthropic -from anthropic import AsyncStream -from anthropic._types import NOT_GIVEN -from anthropic.types import ( - InputJSONDelta, - MessageDeltaUsage, - MessageParam, - MessageStreamEvent, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - RedactedThinkingBlock, - RedactedThinkingBlockParam, - SignatureDelta, - TextBlock, - TextBlockParam, - TextDelta, - ThinkingBlock, - ThinkingBlockParam, - ThinkingConfigDisabledParam, - ThinkingConfigEnabledParam, - ThinkingDelta, - ToolParam, - ToolResultBlockParam, - ToolUseBlock, - ToolUseBlockParam, - Usage, -) -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry -from .const import ( - CONF_CHAT_MODEL, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_TEMPERATURE, - CONF_THINKING_BUDGET, - DOMAIN, - LOGGER, - MIN_THINKING_BUDGET, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_THINKING_BUDGET, - THINKING_MODELS, -) - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 +from .const import CONF_PROMPT, DOMAIN +from .entity import AnthropicBaseLLMEntity async def async_setup_entry( @@ -82,253 +29,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> ToolParam: - """Format tool specification.""" - return ToolParam( - name=tool.name, - description=tool.description or "", - input_schema=convert(tool.parameters, custom_serializer=custom_serializer), - ) - - -def _convert_content( - chat_content: Iterable[conversation.Content], -) -> list[MessageParam]: - """Transform HA chat_log content into Anthropic API format.""" - messages: list[MessageParam] = [] - - for content in chat_content: - if isinstance(content, conversation.ToolResultContent): - tool_result_block = ToolResultBlockParam( - type="tool_result", - tool_use_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - if not messages or messages[-1]["role"] != "user": - messages.append( - MessageParam( - role="user", - content=[tool_result_block], - ) - ) - elif isinstance(messages[-1]["content"], str): - messages[-1]["content"] = [ - TextBlockParam(type="text", text=messages[-1]["content"]), - tool_result_block, - ] - else: - messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] - elif isinstance(content, conversation.UserContent): - # Combine consequent user messages - if not messages or messages[-1]["role"] != "user": - messages.append( - MessageParam( - role="user", - content=content.content, - ) - ) - elif isinstance(messages[-1]["content"], str): - messages[-1]["content"] = [ - TextBlockParam(type="text", text=messages[-1]["content"]), - TextBlockParam(type="text", text=content.content), - ] - else: - messages[-1]["content"].append( # type: ignore[attr-defined] - TextBlockParam(type="text", text=content.content) - ) - elif isinstance(content, conversation.AssistantContent): - # Combine consequent assistant messages - if not messages or messages[-1]["role"] != "assistant": - messages.append( - MessageParam( - role="assistant", - content=[], - ) - ) - - if content.content: - messages[-1]["content"].append( # type: ignore[union-attr] - TextBlockParam(type="text", text=content.content) - ) - if content.tool_calls: - messages[-1]["content"].extend( # type: ignore[union-attr] - [ - ToolUseBlockParam( - type="tool_use", - id=tool_call.id, - name=tool_call.tool_name, - input=tool_call.tool_args, - ) - for tool_call in content.tool_calls - ] - ) - 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)}") - - return messages - - -async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place - chat_log: conversation.ChatLog, - result: AsyncStream[MessageStreamEvent], - messages: list[MessageParam], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - A typical stream of responses might look something like the following: - - RawMessageStartEvent with no content - - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) - - RawContentBlockDeltaEvent with a ThinkingDelta - - RawContentBlockDeltaEvent with a ThinkingDelta - - RawContentBlockDeltaEvent with a ThinkingDelta - - ... - - RawContentBlockDeltaEvent with a SignatureDelta - - RawContentBlockStopEvent - - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) - - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) - - RawContentBlockStartEvent with an empty TextBlock - - RawContentBlockDeltaEvent with a TextDelta - - RawContentBlockDeltaEvent with a TextDelta - - RawContentBlockDeltaEvent with a TextDelta - - ... - - RawContentBlockStopEvent - - RawContentBlockStartEvent with ToolUseBlock specifying the function name - - RawContentBlockDeltaEvent with a InputJSONDelta - - RawContentBlockDeltaEvent with a InputJSONDelta - - ... - - RawContentBlockStopEvent - - RawMessageDeltaEvent with a stop_reason='tool_use' - - RawMessageStopEvent(type='message_stop') - - Each message could contain multiple blocks of the same type. - """ - if result is None: - raise TypeError("Expected a stream of messages") - - current_message: MessageParam | None = None - current_block: ( - TextBlockParam - | ToolUseBlockParam - | ThinkingBlockParam - | RedactedThinkingBlockParam - | None - ) = None - current_tool_args: str - input_usage: Usage | None = None - - async for response in result: - LOGGER.debug("Received response: %s", response) - - if isinstance(response, RawMessageStartEvent): - if response.message.role != "assistant": - raise ValueError("Unexpected message role") - current_message = MessageParam(role=response.message.role, content=[]) - input_usage = response.message.usage - elif isinstance(response, RawContentBlockStartEvent): - if isinstance(response.content_block, ToolUseBlock): - current_block = ToolUseBlockParam( - type="tool_use", - id=response.content_block.id, - name=response.content_block.name, - input="", - ) - current_tool_args = "" - elif isinstance(response.content_block, TextBlock): - current_block = TextBlockParam( - type="text", text=response.content_block.text - ) - yield {"role": "assistant"} - if response.content_block.text: - yield {"content": response.content_block.text} - elif isinstance(response.content_block, ThinkingBlock): - current_block = ThinkingBlockParam( - type="thinking", - thinking=response.content_block.thinking, - signature=response.content_block.signature, - ) - elif isinstance(response.content_block, RedactedThinkingBlock): - current_block = RedactedThinkingBlockParam( - type="redacted_thinking", data=response.content_block.data - ) - LOGGER.debug( - "Some of Claude’s internal reasoning has been automatically " - "encrypted for safety reasons. This doesn’t affect the quality of " - "responses" - ) - elif isinstance(response, RawContentBlockDeltaEvent): - if current_block is None: - raise ValueError("Unexpected delta without a block") - if isinstance(response.delta, InputJSONDelta): - current_tool_args += response.delta.partial_json - elif isinstance(response.delta, TextDelta): - text_block = cast(TextBlockParam, current_block) - text_block["text"] += response.delta.text - yield {"content": response.delta.text} - elif isinstance(response.delta, ThinkingDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["thinking"] += response.delta.thinking - elif isinstance(response.delta, SignatureDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["signature"] += response.delta.signature - elif isinstance(response, RawContentBlockStopEvent): - if current_block is None: - raise ValueError("Unexpected stop event without a current block") - if current_block["type"] == "tool_use": - # tool block - tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_block["input"] = tool_args - yield { - "tool_calls": [ - llm.ToolInput( - id=current_block["id"], - tool_name=current_block["name"], - tool_args=tool_args, - ) - ] - } - elif current_block["type"] == "thinking": - # thinking block - LOGGER.debug("Thinking: %s", current_block["thinking"]) - - if current_message is None: - raise ValueError("Unexpected stop event without a current message") - current_message["content"].append(current_block) # type: ignore[union-attr] - current_block = None - elif isinstance(response, RawMessageDeltaEvent): - if (usage := response.usage) is not None: - chat_log.async_trace(_create_token_stats(input_usage, usage)) - if response.delta.stop_reason == "refusal": - raise HomeAssistantError("Potential policy violation detected") - elif isinstance(response, RawMessageStopEvent): - if current_message is not None: - messages.append(current_message) - current_message = None - - -def _create_token_stats( - input_usage: Usage | None, response_usage: MessageDeltaUsage -) -> dict[str, Any]: - """Create token stats for conversation agent tracing.""" - input_tokens = 0 - cached_input_tokens = 0 - if input_usage: - input_tokens = input_usage.input_tokens - cached_input_tokens = input_usage.cache_creation_input_tokens or 0 - output_tokens = response_usage.output_tokens - return { - "stats": { - "input_tokens": input_tokens, - "cached_input_tokens": cached_input_tokens, - "output_tokens": output_tokens, - } - } - - class AnthropicConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + AnthropicBaseLLMEntity, ): """Anthropic conversation agent.""" @@ -336,17 +40,7 @@ class AnthropicConversationEntity( def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self.subentry = subentry - self._attr_name = subentry.title - self._attr_unique_id = subentry.subentry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, subentry.subentry_id)}, - name=subentry.title, - manufacturer="Anthropic", - model="Claude", - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -357,13 +51,6 @@ class AnthropicConversationEntity( """Return a list of supported languages.""" return MATCH_ALL - async def async_added_to_hass(self) -> None: - """When entity is added to Home Assistant.""" - await super().async_added_to_hass() - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) - async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -384,87 +71,4 @@ class AnthropicConversationEntity( await self._async_handle_chat_log(chat_log) - response_content = chat_log.content[-1] - if not isinstance(response_content, conversation.AssistantContent): - raise TypeError("Last message must be an assistant message") - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_content.content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.subentry.data - - tools: list[ToolParam] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - system = chat_log.content[0] - if not isinstance(system, conversation.SystemContent): - raise TypeError("First message must be a system message") - messages = _convert_content(chat_log.content[1:]) - - client = self.entry.runtime_data - - thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - "system": system.content, - "stream": True, - } - if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: - model_args["thinking"] = ThinkingConfigEnabledParam( - type="enabled", budget_tokens=thinking_budget - ) - else: - model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") - model_args["temperature"] = options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ) - - try: - stream = await client.messages.create(**model_args) - except anthropic.AnthropicError as err: - raise HomeAssistantError( - f"Sorry, I had a problem talking to Anthropic: {err}" - ) from err - - messages.extend( - _convert_content( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_log, stream, messages), - ) - if not isinstance(content, conversation.AssistantContent) - ] - ) - ) - - if not chat_log.unresponded_tool_results: - break - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py new file mode 100644 index 00000000000..7338cbe2906 --- /dev/null +++ b/homeassistant/components/anthropic/entity.py @@ -0,0 +1,401 @@ +"""Base entity for Anthropic.""" + +from collections.abc import AsyncGenerator, Callable, Iterable +import json +from typing import Any + +import anthropic +from anthropic import AsyncStream +from anthropic.types import ( + InputJSONDelta, + MessageDeltaUsage, + MessageParam, + MessageStreamEvent, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, + RawMessageDeltaEvent, + RawMessageStartEvent, + RedactedThinkingBlock, + RedactedThinkingBlockParam, + SignatureDelta, + TextBlock, + TextBlockParam, + TextDelta, + ThinkingBlock, + ThinkingBlockParam, + ThinkingConfigDisabledParam, + ThinkingConfigEnabledParam, + ThinkingDelta, + ToolParam, + ToolResultBlockParam, + ToolUseBlock, + ToolUseBlockParam, + Usage, +) +from anthropic.types.message_create_params import MessageCreateParamsStreaming +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import AnthropicConfigEntry +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_TEMPERATURE, + CONF_THINKING_BUDGET, + DOMAIN, + LOGGER, + MIN_THINKING_BUDGET, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + THINKING_MODELS, +) + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> ToolParam: + """Format tool specification.""" + return ToolParam( + name=tool.name, + description=tool.description or "", + input_schema=convert(tool.parameters, custom_serializer=custom_serializer), + ) + + +def _convert_content( + chat_content: Iterable[conversation.Content], +) -> list[MessageParam]: + """Transform HA chat_log content into Anthropic API format.""" + messages: list[MessageParam] = [] + + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + tool_result_block = ToolResultBlockParam( + type="tool_result", + tool_use_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=[tool_result_block], + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + tool_result_block, + ] + else: + messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] + elif isinstance(content, conversation.UserContent): + # Combine consequent user messages + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=content.content, + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + TextBlockParam(type="text", text=content.content), + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + TextBlockParam(type="text", text=content.content) + ) + elif isinstance(content, conversation.AssistantContent): + # Combine consequent assistant messages + if not messages or messages[-1]["role"] != "assistant": + messages.append( + MessageParam( + role="assistant", + 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, + ) + ) + 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.content: + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam(type="text", text=content.content) + ) + if content.tool_calls: + messages[-1]["content"].extend( # type: ignore[union-attr] + [ + ToolUseBlockParam( + type="tool_use", + id=tool_call.id, + name=tool_call.tool_name, + input=tool_call.tool_args, + ) + for tool_call in content.tool_calls + ] + ) + 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)}") + + return messages + + +async def _transform_stream( + chat_log: conversation.ChatLog, + stream: AsyncStream[MessageStreamEvent], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + A typical stream of responses might look something like the following: + - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - ... + - RawContentBlockDeltaEvent with a SignatureDelta + - RawContentBlockStopEvent + - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) + - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) + - RawContentBlockStartEvent with an empty TextBlock + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - ... + - RawContentBlockStopEvent + - RawContentBlockStartEvent with ToolUseBlock specifying the function name + - RawContentBlockDeltaEvent with a InputJSONDelta + - RawContentBlockDeltaEvent with a InputJSONDelta + - ... + - RawContentBlockStopEvent + - RawMessageDeltaEvent with a stop_reason='tool_use' + - RawMessageStopEvent(type='message_stop') + + Each message could contain multiple blocks of the same type. + """ + if stream is None: + raise TypeError("Expected a stream of messages") + + current_tool_block: ToolUseBlockParam | None = None + current_tool_args: str + input_usage: Usage | None = None + has_content = False + has_native = False + + 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 + elif isinstance(response, RawContentBlockStartEvent): + if isinstance(response.content_block, ToolUseBlock): + current_tool_block = ToolUseBlockParam( + type="tool_use", + id=response.content_block.id, + name=response.content_block.name, + input="", + ) + current_tool_args = "" + elif isinstance(response.content_block, TextBlock): + if has_content: + yield {"role": "assistant"} + has_native = False + has_content = True + if response.content_block.text: + yield {"content": response.content_block.text} + elif isinstance(response.content_block, ThinkingBlock): + if has_native: + yield {"role": "assistant"} + has_native = False + has_content = False + elif isinstance(response.content_block, RedactedThinkingBlock): + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) + if has_native: + yield {"role": "assistant"} + has_native = False + has_content = False + yield {"native": response.content_block} + has_native = True + elif isinstance(response, RawContentBlockDeltaEvent): + if isinstance(response.delta, InputJSONDelta): + current_tool_args += response.delta.partial_json + elif isinstance(response.delta, TextDelta): + yield {"content": response.delta.text} + elif isinstance(response.delta, ThinkingDelta): + yield {"thinking_content": response.delta.thinking} + elif isinstance(response.delta, SignatureDelta): + yield { + "native": ThinkingBlock( + type="thinking", + thinking="", + signature=response.delta.signature, + ) + } + has_native = True + elif isinstance(response, RawContentBlockStopEvent): + if current_tool_block is not None: + tool_args = json.loads(current_tool_args) if current_tool_args else {} + current_tool_block["input"] = tool_args + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_block["id"], + tool_name=current_tool_block["name"], + tool_args=tool_args, + ) + ] + } + current_tool_block = None + elif isinstance(response, RawMessageDeltaEvent): + if (usage := response.usage) is not None: + chat_log.async_trace(_create_token_stats(input_usage, usage)) + if response.delta.stop_reason == "refusal": + raise HomeAssistantError("Potential policy violation detected") + + +def _create_token_stats( + input_usage: Usage | None, response_usage: MessageDeltaUsage +) -> dict[str, Any]: + """Create token stats for conversation agent tracing.""" + input_tokens = 0 + cached_input_tokens = 0 + if input_usage: + input_tokens = input_usage.input_tokens + cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + output_tokens = response_usage.output_tokens + return { + "stats": { + "input_tokens": input_tokens, + "cached_input_tokens": cached_input_tokens, + "output_tokens": output_tokens, + } + } + + +class AnthropicBaseLLMEntity(Entity): + """Anthropic base LLM entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + system = chat_log.content[0] + if not isinstance(system, conversation.SystemContent): + raise TypeError("First message must be a system message") + messages = _convert_content(chat_log.content[1:]) + + client = self.entry.runtime_data + + thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + + model_args = MessageCreateParamsStreaming( + model=model, + messages=messages, + max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + system=system.content, + stream=True, + ) + if tools: + model_args["tools"] = tools + if ( + model.startswith(tuple(THINKING_MODELS)) + and thinking_budget >= MIN_THINKING_BUDGET + ): + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget + ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + 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), + ) + ] + ) + ) + except anthropic.AnthropicError as err: + raise HomeAssistantError( + f"Sorry, I had a problem talking to Anthropic: {err}" + ) from err + + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 6a8f1e5e54c..6fed0282a00 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.52.0"] + "requirements": ["anthropic==0.62.0"] } diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 098b4d5fa74..983260a3c95 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -29,7 +29,7 @@ "set_options": { "data": { "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index dfeb56c8d06..394ff4c4088 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -10,9 +10,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator +from .entity import APCUPSdEntity PARALLEL_UPDATES = 0 @@ -40,22 +40,16 @@ async def async_setup_entry( async_add_entities([OnlineStatus(coordinator, _DESCRIPTION)]) -class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity): +class OnlineStatus(APCUPSdEntity, BinarySensorEntity): """Representation of a UPS online status.""" - _attr_has_entity_name = True - def __init__( self, coordinator: APCUPSdCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialize the APCUPSd binary device.""" - super().__init__(coordinator, context=description.key.upper()) - - self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" - self._attr_device_info = coordinator.device_info + super().__init__(coordinator, description) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/apcupsd/entity.py b/homeassistant/components/apcupsd/entity.py new file mode 100644 index 00000000000..9ebe51ff876 --- /dev/null +++ b/homeassistant/components/apcupsd/entity.py @@ -0,0 +1,26 @@ +"""Base entity for APCUPSd integration.""" + +from __future__ import annotations + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import APCUPSdCoordinator + + +class APCUPSdEntity(CoordinatorEntity[APCUPSdCoordinator]): + """Base entity for APCUPSd integration.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: APCUPSdCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the APCUPSd entity.""" + super().__init__(coordinator, context=description.key.upper()) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" + self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 3713b74fff7..5e5a81c358a 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/apcupsd", "iot_class": "local_polling", "loggers": ["apcaccess"], + "quality_scale": "bronze", "requirements": ["aioapcaccess==0.4.2"] } diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml new file mode 100644 index 00000000000..23b72134d34 --- /dev/null +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + The integration does not provide any additional options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + The integration does not require authentication. + test-coverage: + status: todo + comment: | + Patch `aioapcaccess.request_status` where we use it. + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered. + discovery: + status: exempt + comment: | + This integration cannot be discovered. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + The integration connects to a single service per configuration entry. + 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: done + stale-devices: + status: exempt + comment: | + This integration connect to a single service per configuration entry. + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + The integration does not connect via HTTP. + strict-typing: done diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 5076b537467..14baed5bfce 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -23,10 +23,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import LAST_S_TEST from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator +from .entity import APCUPSdEntity PARALLEL_UPDATES = 0 @@ -490,22 +490,16 @@ def infer_unit(value: str) -> tuple[str, str | None]: return value, None -class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity): +class APCUPSdSensor(APCUPSdEntity, SensorEntity): """Representation of a sensor entity for APCUPSd status values.""" - _attr_has_entity_name = True - def __init__( self, coordinator: APCUPSdCoordinator, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator=coordinator, context=description.key.upper()) - - self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" - self._attr_device_info = coordinator.device_info + super().__init__(coordinator, description) # Initial update of attributes. self._update_attrs() diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index d821b66ef67..8f237fd41fe 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -14,7 +14,22 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of the APC UPS Daemon", + "port": "The port the APC UPS Daemon is listening on" + }, "description": "Enter the host and port on which the apcupsd NIS is being served." + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::apcupsd::config::step::user::data_description::host%]", + "port": "[%key:component::apcupsd::config::step::user::data_description::port%]" + }, + "description": "[%key:component::apcupsd::config::step::user::description%]" } } }, diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index d183d46a717..242c21eb524 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -260,11 +260,18 @@ class APIEntityStateView(HomeAssistantView): if not user.is_admin: raise Unauthorized(entity_id=entity_id) hass = request.app[KEY_HASS] + + body = await request.text() + try: - data = await request.json() + data: Any = json_loads(body) if body else None except ValueError: return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST) + if not isinstance(data, dict): + return self.json_message( + "State data should be a JSON object.", HTTPStatus.BAD_REQUEST + ) if (new_state := data.get("state")) is None: return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST) @@ -477,9 +484,19 @@ class APITemplateView(HomeAssistantView): @require_admin async def post(self, request: web.Request) -> web.Response: """Render a template.""" + body = await request.text() + + try: + data: Any = json_loads(body) if body else None + except ValueError: + return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST) + + if not isinstance(data, dict): + return self.json_message( + "Template data should be a JSON object.", HTTPStatus.BAD_REQUEST + ) + tpl = _cached_template(data["template"], request.app[KEY_HASS]) try: - data = await request.json() - tpl = _cached_template(data["template"], request.app[KEY_HASS]) return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return] except (ValueError, TemplateError) as ex: return self.json_message( diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index b10a14af32b..fe500d2bfb0 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.16.0"], + "requirements": ["pyatv==0.16.1"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index b6d451a9ea0..12a27fb195f 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -191,7 +191,7 @@ class AppleTvMediaPlayer( self._is_feature_available(FeatureName.PowerState) and self.atv.power.power_state == PowerState.Off ): - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF if self._playing: state = self._playing.device_state if state in (DeviceState.Idle, DeviceState.Loading): @@ -200,7 +200,7 @@ class AppleTvMediaPlayer( return MediaPlayerState.PLAYING if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped): return MediaPlayerState.PAUSED - return MediaPlayerState.STANDBY # Bad or unknown state? + return MediaPlayerState.IDLE # Bad or unknown state? return None @callback diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 41396eca5d6..eb8764e1596 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.8.1"], + "requirements": ["arcam-fmj==1.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 59bd987d90e..8f4c6efd355 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -38,8 +38,6 @@ from .pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, - async_run_migrations, async_setup_pipeline_store, async_update_pipeline, ) @@ -61,7 +59,6 @@ __all__ = ( "WakeWordSettings", "async_create_default_pipeline", "async_get_pipelines", - "async_migrate_engine", "async_pipeline_from_audio_stream", "async_setup", "async_update_pipeline", @@ -87,7 +84,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_LAST_WAKE_UP] = {} await async_setup_pipeline_store(hass) - await async_run_migrations(hass) async_register_websocket_api(hass) return True diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 300cb5aad2a..52583cf21a4 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -3,7 +3,6 @@ DOMAIN = "assist_pipeline" DATA_CONFIG = f"{DOMAIN}.config" -DATA_MIGRATIONS = f"{DOMAIN}_migrations" DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 93e857f4b2b..0cd593e9666 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -13,7 +13,7 @@ from pathlib import Path from queue import Empty, Queue from threading import Thread import time -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, cast import wave import hass_nabucasa @@ -49,7 +49,6 @@ from .const import ( CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, - DATA_MIGRATIONS, DOMAIN, MS_PER_CHUNK, SAMPLE_CHANNELS, @@ -1119,6 +1118,7 @@ class PipelineRun: ) is not None: # Sentence trigger matched agent_id = "sentence_trigger" + processed_locally = True intent_response = intent.IntentResponse( self.pipeline.conversation_language ) @@ -2058,50 +2058,6 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: return PipelineData(pipeline_store) -@callback -def async_migrate_engine( - hass: HomeAssistant, - engine_type: Literal["conversation", "stt", "tts", "wake_word"], - old_value: str, - new_value: str, -) -> None: - """Register a migration of an engine used in pipelines.""" - hass.data.setdefault(DATA_MIGRATIONS, {})[engine_type] = (old_value, new_value) - - # Run migrations when config is already loaded - if DATA_CONFIG in hass.data: - hass.async_create_background_task( - async_run_migrations(hass), "assist_pipeline_migration", eager_start=True - ) - - -async def async_run_migrations(hass: HomeAssistant) -> None: - """Run pipeline migrations.""" - if not (migrations := hass.data.get(DATA_MIGRATIONS)): - return - - engine_attr = { - "conversation": "conversation_engine", - "stt": "stt_engine", - "tts": "tts_engine", - "wake_word": "wake_word_entity", - } - - updates = [] - - for pipeline in async_get_pipelines(hass): - attr_updates = {} - for engine_type, (old_value, new_value) in migrations.items(): - if getattr(pipeline, engine_attr[engine_type]) == old_value: - attr_updates[engine_attr[engine_type]] = new_value - - if attr_updates: - updates.append((pipeline, attr_updates)) - - for pipeline, attr_updates in updates: - await async_update_pipeline(hass, pipeline, **attr_updates) - - @dataclass class PipelineConversationData: """Hold data for the duration of a conversation.""" diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 6bfbdfb33a8..62dcb8c1d80 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Optional("message"): str, - vol.Optional("media_id"): str, - vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("media_id"): _media_id_validator, + vol.Optional("preannounce", default=True): bool, + vol.Optional("preannounce_media_id"): _media_id_validator, } ), cv.has_at_least_one_key("message", "media_id"), @@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( "start_conversation", vol.All( cv.make_entity_service_schema( { vol.Optional("start_message"): str, - vol.Optional("start_media_id"): str, - vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("start_media_id"): _media_id_validator, + vol.Optional("preannounce", default=True): bool, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("extra_system_prompt"): str, } ), @@ -113,7 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ask_question_args = { "question": call.data.get("question"), "question_media_id": call.data.get("question_media_id"), - "preannounce": call.data.get("preannounce", False), + "preannounce": call.data.get("preannounce", True), "answers": call.data.get("answers"), } @@ -135,9 +136,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Optional("question"): str, - vol.Optional("question_media_id"): str, - vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("question_media_id"): _media_id_validator, + vol.Optional("preannounce", default=True): bool, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("answers"): [ { vol.Required("id"): str, @@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]: raise vol.Invalid("sentences cannot be empty") return value + + +# Validator for media_id fields that accepts both string and media selector format +_media_id_validator = vol.Any( + cv.string, # Plain string format + vol.All( + vol.Schema( + { + vol.Required("media_content_id"): cv.string, + vol.Required("media_content_type"): cv.string, + vol.Remove("metadata"): dict, # Ignore metadata if present + } + ), + # Extract media_content_id from media selector format + lambda x: x["media_content_id"], + ), +) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e7a10ef63f6..3d562544c68 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -11,7 +11,7 @@ import time from typing import Any, Literal, final from hassil import Intents, recognize -from hassil.expression import Expression, ListReference, Sequence +from hassil.expression import Expression, Group, ListReference from hassil.intents import WildcardSlotList from homeassistant.components import conversation, media_source, stt, tts @@ -413,7 +413,7 @@ class AssistSatelliteEntity(entity.Entity): for intent in intents.intents.values(): for intent_data in intent.data: for sentence in intent_data.sentences: - _collect_list_references(sentence, wildcard_names) + _collect_list_references(sentence.expression, wildcard_names) for wildcard_name in wildcard_names: intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) @@ -727,9 +727,9 @@ class AssistSatelliteEntity(entity.Entity): def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" - if isinstance(expression, Sequence): - seq: Sequence = expression - for item in seq.items: + if isinstance(expression, Group): + grp: Group = expression + for item in grp.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 97362f157e4..184de576050 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==2.2.3"] + "requirements": ["hassil==3.1.0"] } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index c5484e22dad..ed292e1626c 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,7 +14,9 @@ announce: media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -23,7 +25,9 @@ announce: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* start_conversation: target: entity: @@ -40,7 +44,9 @@ start_conversation: start_media_id: required: false selector: - text: + media: + accept: + - audio/* extra_system_prompt: required: false selector: @@ -53,16 +59,19 @@ start_conversation: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* ask_question: fields: entity_id: required: true selector: entity: - domain: assist_satellite - supported_features: - - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + filter: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION question: required: false example: "What kind of music would you like to play?" @@ -72,7 +81,9 @@ ask_question: question_media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -81,8 +92,24 @@ ask_question: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* answers: required: false selector: object: + label_field: sentences + description_field: id + multiple: true + translation_key: answers + fields: + id: + required: true + selector: + text: + sentences: + required: true + selector: + text: + multiple: true diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index e0bf2bcfb94..52df2492480 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -90,5 +90,13 @@ } } } + }, + "selector": { + "answers": { + "fields": { + "id": "Answer ID", + "sentences": "Sentences" + } + } } } diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index bc6f0fe6fd2..b5042d07b82 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -5,15 +5,16 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections import namedtuple from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime import functools import logging from typing import Any, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aiohttp import ClientSession -from pyasuswrt import AsusWrtError, AsusWrtHttp -from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError +from asusrouter import AsusRouter, AsusRouterError +from asusrouter.modules.client import AsusClient +from asusrouter.modules.data import AsusData +from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors from homeassistant.const import ( CONF_HOST, @@ -41,14 +42,13 @@ from .const import ( PROTOCOL_HTTPS, PROTOCOL_TELNET, SENSORS_BYTES, - SENSORS_CPU, SENSORS_LOAD_AVG, SENSORS_MEMORY, SENSORS_RATES, - SENSORS_TEMPERATURES, SENSORS_TEMPERATURES_LEGACY, SENSORS_UPTIME, ) +from .helpers import clean_dict, translate_to_legacy SENSORS_TYPE_BYTES = "sensors_bytes" SENSORS_TYPE_COUNT = "sensors_count" @@ -310,16 +310,16 @@ class AsusWrtHttpBridge(AsusWrtBridge): def __init__(self, conf: dict[str, Any], session: ClientSession) -> None: """Initialize Bridge that use HTTP library.""" super().__init__(conf[CONF_HOST]) - self._api: AsusWrtHttp = self._get_api(conf, session) + self._api = self._get_api(conf, session) @staticmethod - def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusWrtHttp: - """Get the AsusWrtHttp API.""" - return AsusWrtHttp( - conf[CONF_HOST], - conf[CONF_USERNAME], - conf.get(CONF_PASSWORD, ""), - use_https=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS, + def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusRouter: + """Get the AsusRouter API.""" + return AsusRouter( + hostname=conf[CONF_HOST], + username=conf[CONF_USERNAME], + password=conf.get(CONF_PASSWORD, ""), + use_ssl=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS, port=conf.get(CONF_PORT), session=session, ) @@ -327,46 +327,90 @@ class AsusWrtHttpBridge(AsusWrtBridge): @property def is_connected(self) -> bool: """Get connected status.""" - return cast(bool, self._api.is_connected) + return self._api.connected async def async_connect(self) -> None: """Connect to the device.""" await self._api.async_connect() + # Collect the identity + _identity = await self._api.async_get_identity() + # get main router properties - if mac := self._api.mac: + if mac := _identity.mac: self._label_mac = format_mac(mac) - self._firmware = self._api.firmware - self._model = self._api.model + self._firmware = str(_identity.firmware) + self._model = _identity.model async def async_disconnect(self) -> None: """Disconnect to the device.""" await self._api.async_disconnect() + async def _get_data( + self, + datatype: AsusData, + force: bool = False, + ) -> dict[str, Any]: + """Get data from the device. + + This is a generic method which automatically converts to + the Home Assistant-compatible format. + """ + try: + raw = await self._api.async_get_data(datatype, force=force) + return translate_to_legacy(clean_dict(convert_to_ha_data(raw))) + except AsusRouterError as ex: + raise UpdateFailed(ex) from ex + + async def _get_sensors(self, datatype: AsusData) -> list[str]: + """Get the available sensors. + + This is a generic method which automatically converts to + the Home Assistant-compatible format. + """ + sensors = [] + try: + data = await self._api.async_get_data(datatype) + # Get the list of sensors from the raw data + # and translate in to the legacy format + sensors = translate_to_legacy(convert_to_ha_sensors(data, datatype)) + _LOGGER.debug("Available `%s` sensors: %s", datatype.value, sensors) + except AsusRouterError as ex: + _LOGGER.warning( + "Cannot get available `%s` sensors with exception: %s", + datatype.value, + ex, + ) + return sensors + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: """Get list of connected devices.""" - api_devices = await self._api.async_get_connected_devices() + api_devices: dict[str, AsusClient] = await self._api.async_get_data( + AsusData.CLIENTS, force=True + ) return { - format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node) + format_mac(mac): WrtDevice( + dev.connection.ip_address, dev.description.name, dev.connection.node + ) for mac, dev in api_devices.items() + if dev.connection is not None + and dev.description is not None + and dev.connection.ip_address is not None } async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: """Return a dictionary of available sensors for this bridge.""" - sensors_cpu = await self._get_available_cpu_sensors() - sensors_temperatures = await self._get_available_temperature_sensors() - sensors_loadavg = await self._get_loadavg_sensors_availability() return { SENSORS_TYPE_BYTES: { KEY_SENSORS: SENSORS_BYTES, KEY_METHOD: self._get_bytes, }, SENSORS_TYPE_CPU: { - KEY_SENSORS: sensors_cpu, + KEY_SENSORS: await self._get_sensors(AsusData.CPU), KEY_METHOD: self._get_cpu_usage, }, SENSORS_TYPE_LOAD_AVG: { - KEY_SENSORS: sensors_loadavg, + KEY_SENSORS: await self._get_sensors(AsusData.SYSINFO), KEY_METHOD: self._get_load_avg, }, SENSORS_TYPE_MEMORY: { @@ -382,95 +426,44 @@ class AsusWrtHttpBridge(AsusWrtBridge): KEY_METHOD: self._get_uptime, }, SENSORS_TYPE_TEMPERATURES: { - KEY_SENSORS: sensors_temperatures, + KEY_SENSORS: await self._get_sensors(AsusData.TEMPERATURE), KEY_METHOD: self._get_temperatures, }, } - async def _get_available_cpu_sensors(self) -> list[str]: - """Check which cpu information is available on the router.""" - try: - available_cpu = await self._api.async_get_cpu_usage() - available_sensors = [t for t in SENSORS_CPU if t in available_cpu] - except AsusWrtError as exc: - _LOGGER.warning( - ( - "Failed checking cpu sensor availability for ASUS router" - " %s. Exception: %s" - ), - self.host, - exc, - ) - return [] - return available_sensors - - async def _get_available_temperature_sensors(self) -> list[str]: - """Check which temperature information is available on the router.""" - try: - available_temps = await self._api.async_get_temperatures() - available_sensors = [ - t for t in SENSORS_TEMPERATURES if t in available_temps - ] - except AsusWrtError as exc: - _LOGGER.warning( - ( - "Failed checking temperature sensor availability for ASUS router" - " %s. Exception: %s" - ), - self.host, - exc, - ) - return [] - return available_sensors - - async def _get_loadavg_sensors_availability(self) -> list[str]: - """Check if load avg is available on the router.""" - try: - await self._api.async_get_loadavg() - except AsusWrtNotAvailableInfoError: - return [] - except AsusWrtError: - pass - return SENSORS_LOAD_AVG - - @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES) async def _get_bytes(self) -> Any: """Fetch byte information from the router.""" - return await self._api.async_get_traffic_bytes() + return await self._get_data(AsusData.NETWORK) - @handle_errors_and_zip(AsusWrtError, SENSORS_RATES) async def _get_rates(self) -> Any: """Fetch rates information from the router.""" - return await self._api.async_get_traffic_rates() + data = await self._get_data(AsusData.NETWORK) + # Convert from bits/s to Bytes/s for compatibility with legacy sensors + return { + key: ( + value / 8 + if key in SENSORS_RATES and isinstance(value, (int, float)) + else value + ) + for key, value in data.items() + } - @handle_errors_and_zip(AsusWrtError, SENSORS_LOAD_AVG) async def _get_load_avg(self) -> Any: """Fetch cpu load avg information from the router.""" - return await self._api.async_get_loadavg() + return await self._get_data(AsusData.SYSINFO) - @handle_errors_and_zip(AsusWrtError, None) async def _get_temperatures(self) -> Any: """Fetch temperatures information from the router.""" - return await self._api.async_get_temperatures() + return await self._get_data(AsusData.TEMPERATURE) - @handle_errors_and_zip(AsusWrtError, None) async def _get_cpu_usage(self) -> Any: """Fetch cpu information from the router.""" - return await self._api.async_get_cpu_usage() + return await self._get_data(AsusData.CPU) - @handle_errors_and_zip(AsusWrtError, None) async def _get_memory_usage(self) -> Any: """Fetch memory information from the router.""" - return await self._api.async_get_memory_usage() + return await self._get_data(AsusData.RAM) async def _get_uptime(self) -> dict[str, Any]: """Fetch uptime from the router.""" - try: - uptimes = await self._api.async_get_uptime() - except AsusWrtError as exc: - raise UpdateFailed(exc) from exc - - last_boot = datetime.fromisoformat(uptimes["last_boot"]) - uptime = uptimes["uptime"] - - return dict(zip(SENSORS_UPTIME, [last_boot, uptime], strict=False)) + return await self._get_data(AsusData.BOOTTIME) diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index d58a216aaee..a86f7e08318 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -7,7 +7,7 @@ import os import socket from typing import Any, cast -from pyasuswrt import AsusWrtError +from asusrouter import AsusRouterError import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -189,7 +189,7 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): try: await api.async_connect() - except (AsusWrtError, OSError): + except (AsusRouterError, OSError): _LOGGER.error( "Error connecting to the AsusWrt router at %s using protocol %s", host, diff --git a/homeassistant/components/asuswrt/helpers.py b/homeassistant/components/asuswrt/helpers.py new file mode 100644 index 00000000000..0fb467e6046 --- /dev/null +++ b/homeassistant/components/asuswrt/helpers.py @@ -0,0 +1,56 @@ +"""Helpers for AsusWRT integration.""" + +from __future__ import annotations + +from typing import Any, TypeVar + +T = TypeVar("T", dict[str, Any], list[Any], None) + +TRANSLATION_MAP = { + "wan_rx": "sensor_rx_bytes", + "wan_tx": "sensor_tx_bytes", + "total_usage": "cpu_total_usage", + "usage": "mem_usage_perc", + "free": "mem_free", + "used": "mem_used", + "wan_rx_speed": "sensor_rx_rates", + "wan_tx_speed": "sensor_tx_rates", + "2ghz": "2.4GHz", + "5ghz": "5.0GHz", + "5ghz2": "5.0GHz_2", + "6ghz": "6.0GHz", + "cpu": "CPU", + "datetime": "sensor_last_boot", + "uptime": "sensor_uptime", + **{f"{num}_usage": f"cpu{num}_usage" for num in range(1, 9)}, + **{f"load_avg_{load}": f"sensor_load_avg{load}" for load in ("1", "5", "15")}, +} + + +def clean_dict(raw: dict[str, Any]) -> dict[str, Any]: + """Cleans dictionary from None values. + + The `state` key is always preserved regardless of its value. + """ + + return {k: v for k, v in raw.items() if v is not None or k.endswith("state")} + + +def translate_to_legacy(raw: T) -> T: + """Translate raw data to legacy format for dicts and lists.""" + + if raw is None: + return None + + if isinstance(raw, dict): + return {TRANSLATION_MAP.get(k, k): v for k, v in raw.items()} + + if isinstance(raw, list): + return [ + TRANSLATION_MAP[item] + if isinstance(item, str) and item in TRANSLATION_MAP + else item + for item in raw + ] + + return raw diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index f4b2e3386e9..36ab9801bca 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -1,11 +1,11 @@ { "domain": "asuswrt", "name": "ASUSWRT", - "codeowners": ["@kennedyshead", "@ollo69"], + "codeowners": ["@kennedyshead", "@ollo69", "@Vaskivskyi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/asuswrt", "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.21"] + "loggers": ["aioasuswrt", "asusrouter", "asyncssh"], + "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.19.0"] } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index a34f191b7a7..c777535e242 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -5,9 +5,9 @@ from __future__ import annotations from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from pyasuswrt import AsusWrtError +from asusrouter import AsusRouterError from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, @@ -40,6 +40,9 @@ from .const import ( SENSORS_CONNECTED_DEVICE, ) +if TYPE_CHECKING: + from . import AsusWrtConfigEntry + CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP] SCAN_INTERVAL = timedelta(seconds=30) @@ -52,10 +55,13 @@ _LOGGER = logging.getLogger(__name__) class AsusWrtSensorDataHandler: """Data handler for AsusWrt sensor.""" - def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None: + def __init__( + self, hass: HomeAssistant, api: AsusWrtBridge, entry: AsusWrtConfigEntry + ) -> None: """Initialize a AsusWrt sensor data handler.""" self._hass = hass self._api = api + self._entry = entry self._connected_devices = 0 async def _get_connected_devices(self) -> dict[str, int]: @@ -91,6 +97,7 @@ class AsusWrtSensorDataHandler: update_method=method, # Polling interval. Will only be polled if there are subscribers. update_interval=SCAN_INTERVAL if should_poll else None, + config_entry=self._entry, ) await coordinator.async_refresh() @@ -222,7 +229,7 @@ class AsusWrtRouter: """Set up a AsusWrt router.""" try: await self._api.async_connect() - except (AsusWrtError, OSError) as exc: + except (AsusRouterError, OSError) as exc: raise ConfigEntryNotReady from exc if not self._api.is_connected: raise ConfigEntryNotReady @@ -277,7 +284,7 @@ class AsusWrtRouter: _LOGGER.debug("Checking devices for ASUS router %s", self.host) try: wrt_devices = await self._api.async_get_connected_devices() - except (OSError, AsusWrtError) as exc: + except (OSError, AsusRouterError) as exc: if not self._connect_error: self._connect_error = True _LOGGER.error( @@ -321,7 +328,9 @@ class AsusWrtRouter: if self._sensors_data_handler: return - self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api) + self._sensors_data_handler = AsusWrtSensorDataHandler( + self.hass, self._api, self._entry + ) self._sensors_data_handler.update_device_count(self._connected_devices) sensors_types = await self._api.async_get_available_sensors() diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index a6b2961c2a0..51c5225b894 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] + "requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index d27235123b9..69ae3eb65bd 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -199,23 +199,19 @@ class AuthProvidersView(HomeAssistantView): ) -def _prepare_result_json( - result: AuthFlowResult, -) -> AuthFlowResult: - """Convert result to JSON.""" +def _prepare_result_json(result: AuthFlowResult) -> dict[str, Any]: + """Convert result to JSON serializable dict.""" if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: - data = result.copy() - data.pop("result") - data.pop("data") - return data + return { + key: val for key, val in result.items() if key not in ("result", "data") + } if result["type"] != data_entry_flow.FlowResultType.FORM: - return result + return result # type: ignore[return-value] - data = result.copy() - - if (schema := data["data_schema"]) is None: - data["data_schema"] = [] # type: ignore[typeddict-item] # json result type + data = dict(result) + if (schema := result["data_schema"]) is None: + data["data_schema"] = [] else: data["data_schema"] = voluptuous_serialize.convert(schema) @@ -268,7 +264,7 @@ class LoginFlowBaseView(HomeAssistantView): result.pop("data") result.pop("context") - result_obj: Credentials = result.pop("result") + result_obj = result.pop("result") # Result can be None if credential was never linked to a user before. user = await hass.auth.async_get_user_by_credentials(result_obj) @@ -281,7 +277,8 @@ class LoginFlowBaseView(HomeAssistantView): ) process_success_login(request) - result["result"] = self._store_result(client_id, result_obj) + # We overwrite the Credentials object with the string code to retrieve it. + result["result"] = self._store_result(client_id, result_obj) # type: ignore[typeddict-item] return self.json(result) diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 6c85f5b7f55..5b4a539b86f 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -149,20 +149,16 @@ def websocket_depose_mfa( hass.async_create_task(async_depose(msg)) -def _prepare_result_json( - result: data_entry_flow.FlowResult, -) -> data_entry_flow.FlowResult: - """Convert result to JSON.""" +def _prepare_result_json(result: data_entry_flow.FlowResult) -> dict[str, Any]: + """Convert result to JSON serializable dict.""" if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: - return result.copy() - + return dict(result) if result["type"] != data_entry_flow.FlowResultType.FORM: - return result + return result # type: ignore[return-value] - data = result.copy() - - if (schema := data["data_schema"]) is None: - data["data_schema"] = [] # type: ignore[typeddict-item] # json result type + data = dict(result) + if (schema := result["data_schema"]) is None: + data["data_schema"] = [] else: data["data_schema"] = voluptuous_serialize.convert(schema) diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index a1c5781e9a4..a7bb8a0c550 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -5,7 +5,9 @@ from __future__ import annotations from datetime import timedelta import logging +API_ABS_HUMID = "abs_humid" API_CO2 = "carbon_dioxide" +API_DEW_POINT = "dew_point" API_DUST = "dust" API_HUMID = "humidity" API_LUX = "illuminance" diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index a0c4b5ba8fe..b0a44cb3e17 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_SW_VERSION, + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, @@ -33,7 +34,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + API_ABS_HUMID, API_CO2, + API_DEW_POINT, API_DUST, API_HUMID, API_LUX, @@ -110,6 +113,23 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( unique_id_tag="CO2", # matches legacy format state_class=SensorStateClass.MEASUREMENT, ), + AwairSensorEntityDescription( + key=API_DEW_POINT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="dew_point", + unique_id_tag="dew_point", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + AwairSensorEntityDescription( + key=API_ABS_HUMID, + device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY, + native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, + unique_id_tag="absolute_humidity", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ) SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json index a7c5c647af8..30425d2e1bc 100644 --- a/homeassistant/components/awair/strings.json +++ b/homeassistant/components/awair/strings.json @@ -57,6 +57,9 @@ }, "sound_level": { "name": "Sound level" + }, + "dew_point": { + "name": "Dew point" } } } diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index e6c6fab47a1..92bd240c736 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -30,7 +30,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: AxisConfigEntry) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) hub.setup() - config_entry.add_update_listener(hub.async_new_address_callback) + config_entry.async_on_unload( + config_entry.add_update_listener(hub.async_new_address_callback) + ) config_entry.async_on_unload(hub.teardown) config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 9758af60178..1a125516130 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==64"], + "requirements": ["axis==65"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 51503230530..f3289d6e744 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -2,9 +2,9 @@ from homeassistant.config_entries import SOURCE_SYSTEM from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, discovery_flow -from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -37,7 +37,6 @@ from .manager import ( IdleEvent, IncorrectPasswordError, ManagerBackup, - ManagerStateEvent, NewBackup, RestoreBackupEvent, RestoreBackupStage, @@ -45,6 +44,7 @@ from .manager import ( WrittenBackup, ) from .models import AddonInfo, AgentBackup, BackupNotFound, Folder +from .services import async_setup_services from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers @@ -71,12 +71,12 @@ __all__ = [ "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", - "ManagerStateEvent", "NewBackup", "RestoreBackupEvent", "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", + "async_get_manager", "suggested_filename", "suggested_filename_from_name_date", ] @@ -103,39 +103,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: backup_manager = BackupManager(hass, reader_writer) hass.data[DATA_MANAGER] = backup_manager - try: - await backup_manager.async_setup() - except Exception as err: - hass.data[DATA_BACKUP].manager_ready.set_exception(err) - raise - else: - hass.data[DATA_BACKUP].manager_ready.set_result(None) + await backup_manager.async_setup() async_register_websocket_handlers(hass, with_hassio) - async def async_handle_create_service(call: ServiceCall) -> None: - """Service handler for creating backups.""" - agent_id = list(backup_manager.local_backup_agents)[0] - await backup_manager.async_create_backup( - agent_ids=[agent_id], - include_addons=None, - include_all_addons=False, - include_database=True, - include_folders=None, - include_homeassistant=True, - name=None, - password=None, - ) - - async def async_handle_create_automatic_service(call: ServiceCall) -> None: - """Service handler for creating automatic backups.""" - await backup_manager.async_create_automatic_backup() - - if not with_hassio: - hass.services.async_register(DOMAIN, "create", async_handle_create_service) - hass.services.async_register( - DOMAIN, "create_automatic", async_handle_create_automatic_service - ) + async_setup_services(hass) async_register_http_views(hass) @@ -164,3 +136,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +@callback +def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_MANAGER not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + return hass.data[DATA_MANAGER] diff --git a/homeassistant/components/backup/basic_websocket.py b/homeassistant/components/backup/basic_websocket.py deleted file mode 100644 index 614dc23a927..00000000000 --- a/homeassistant/components/backup/basic_websocket.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Websocket commands for the Backup integration.""" - -from typing import Any - -import voluptuous as vol - -from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.backup import async_subscribe_events - -from .const import DATA_MANAGER -from .manager import ManagerStateEvent - - -@callback -def async_register_websocket_handlers(hass: HomeAssistant) -> None: - """Register websocket commands.""" - websocket_api.async_register_command(hass, handle_subscribe_events) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) -@websocket_api.async_response -async def handle_subscribe_events( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Subscribe to backup events.""" - - def on_event(event: ManagerStateEvent) -> None: - connection.send_message(websocket_api.event_message(msg["id"], event)) - - if DATA_MANAGER in hass.data: - manager = hass.data[DATA_MANAGER] - on_event(manager.last_event) - connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event) - connection.send_result(msg["id"]) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 0c8a5c82f7c..e4feb7dd8bd 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -127,7 +127,6 @@ class BackupConfigData: schedule=BackupSchedule( days=days, recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]), - state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)), time=time, ), ) @@ -453,7 +452,6 @@ class StoredBackupSchedule(TypedDict): days: list[Day] recurrence: ScheduleRecurrence - state: ScheduleState time: str | None @@ -462,7 +460,6 @@ class ScheduleParametersDict(TypedDict, total=False): days: list[Day] recurrence: ScheduleRecurrence - state: ScheduleState time: dt.time | None @@ -486,32 +483,12 @@ class ScheduleRecurrence(StrEnum): CUSTOM_DAYS = "custom_days" -class ScheduleState(StrEnum): - """Represent the schedule recurrence. - - This is deprecated and can be remove in HA Core 2025.8. - """ - - NEVER = "never" - DAILY = "daily" - MONDAY = "mon" - TUESDAY = "tue" - WEDNESDAY = "wed" - THURSDAY = "thu" - FRIDAY = "fri" - SATURDAY = "sat" - SUNDAY = "sun" - - @dataclass(kw_only=True) class BackupSchedule: """Represent the backup schedule.""" days: list[Day] = field(default_factory=list) recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER - # Although no longer used, state is kept for backwards compatibility. - # It can be removed in HA Core 2025.8. - state: ScheduleState = ScheduleState.NEVER time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) next_automatic_backup: datetime | None = field(init=False, default=None) @@ -610,7 +587,6 @@ class BackupSchedule: return StoredBackupSchedule( days=self.days, recurrence=self.recurrence, - state=self.state, time=self.time.isoformat() if self.time else None, ) diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py index 3f6146f68d7..1a3429578c2 100644 --- a/homeassistant/components/backup/coordinator.py +++ b/homeassistant/components/backup/coordinator.py @@ -8,10 +8,6 @@ from datetime import datetime from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.backup import ( - async_subscribe_events, - async_subscribe_platform_events, -) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER @@ -56,8 +52,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): update_interval=None, ) self.unsubscribe: list[Callable[[], None]] = [ - async_subscribe_events(hass, self._on_event), - async_subscribe_platform_events(hass, self._on_event), + backup_manager.async_subscribe_events(self._on_event), + backup_manager.async_subscribe_platform_events(self._on_event), ] self.backup_manager = backup_manager diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8dbce1b455c..f1b2f7d5b97 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -36,7 +36,6 @@ from homeassistant.helpers import ( issue_registry as ir, start, ) -from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util, json as json_util @@ -372,12 +371,10 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = BlockedEvent() self.last_action_event: ManagerStateEvent | None = None - self._backup_event_subscriptions = hass.data[ - DATA_BACKUP - ].backup_event_subscriptions - self._backup_platform_event_subscriptions = hass.data[ - DATA_BACKUP - ].backup_platform_event_subscriptions + self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] + self._backup_platform_event_subscriptions: list[ + Callable[[BackupPlatformEvent], None] + ] = [] async def async_setup(self) -> None: """Set up the backup manager.""" @@ -1122,7 +1119,7 @@ class BackupManager: ) if unavailable_agents: LOGGER.warning( - "Backup agents %s are not available, will backupp to %s", + "Backup agents %s are not available, will backup to %s", unavailable_agents, available_agents, ) @@ -1385,6 +1382,32 @@ class BackupManager: for subscription in self._backup_event_subscriptions: subscription(event) + @callback + def async_subscribe_events( + self, + on_event: Callable[[ManagerStateEvent], None], + ) -> Callable[[], None]: + """Subscribe events.""" + + def remove_subscription() -> None: + self._backup_event_subscriptions.remove(on_event) + + self._backup_event_subscriptions.append(on_event) + return remove_subscription + + @callback + def async_subscribe_platform_events( + self, + on_event: Callable[[BackupPlatformEvent], None], + ) -> Callable[[], None]: + """Subscribe to backup platform events.""" + + def remove_subscription() -> None: + self._backup_platform_event_subscriptions.remove(on_event) + + self._backup_platform_event_subscriptions.append(on_event) + return remove_subscription + def _create_automatic_backup_failed_issue( self, translation_key: str, translation_placeholders: dict[str, str] | None ) -> None: diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py index ad7027c988c..dad0d5e7e35 100644 --- a/homeassistant/components/backup/onboarding.py +++ b/homeassistant/components/backup/onboarding.py @@ -19,9 +19,14 @@ from homeassistant.components.onboarding import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager -from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http +from . import ( + BackupManager, + Folder, + IncorrectPasswordError, + async_get_manager, + http as backup_http, +) if TYPE_CHECKING: from homeassistant.components.onboarding import OnboardingStoreData @@ -54,7 +59,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( if self._data["done"]: raise HTTPUnauthorized - manager = await async_get_backup_manager(request.app[KEY_HASS]) + manager = async_get_manager(request.app[KEY_HASS]) return await func(self, manager, request, *args, **kwargs) return with_backup diff --git a/homeassistant/components/backup/services.py b/homeassistant/components/backup/services.py new file mode 100644 index 00000000000..17448f7bb06 --- /dev/null +++ b/homeassistant/components/backup/services.py @@ -0,0 +1,36 @@ +"""The Backup integration.""" + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers.hassio import is_hassio + +from .const import DATA_MANAGER, DOMAIN + + +async def _async_handle_create_service(call: ServiceCall) -> None: + """Service handler for creating backups.""" + backup_manager = call.hass.data[DATA_MANAGER] + agent_id = list(backup_manager.local_backup_agents)[0] + await backup_manager.async_create_backup( + agent_ids=[agent_id], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=None, + password=None, + ) + + +async def _async_handle_create_automatic_service(call: ServiceCall) -> None: + """Service handler for creating automatic backups.""" + await call.hass.data[DATA_MANAGER].async_create_automatic_backup() + + +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + if not is_hassio(hass): + hass.services.async_register(DOMAIN, "create", _async_handle_create_service) + hass.services.async_register( + DOMAIN, "create_automatic", _async_handle_create_automatic_service + ) diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 080b5bb18a8..d7e9b600155 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -10,7 +10,11 @@ from homeassistant.helpers import config_validation as cv from .config import Day, ScheduleRecurrence from .const import DATA_MANAGER, LOGGER -from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError +from .manager import ( + DecryptOnDowloadNotSupported, + IncorrectPasswordError, + ManagerStateEvent, +) from .models import BackupNotFound, Folder @@ -30,6 +34,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_restore) + websocket_api.async_register_command(hass, handle_subscribe_events) websocket_api.async_register_command(hass, handle_config_info) websocket_api.async_register_command(hass, handle_config_update) @@ -326,9 +331,6 @@ async def handle_config_info( """Send the stored backup config.""" manager = hass.data[DATA_MANAGER] config = manager.config.data.to_dict() - # Remove state from schedule, it's not needed in the frontend - # mypy doesn't like deleting from TypedDict, ignore it - del config["schedule"]["state"] # type: ignore[misc] connection.send_result( msg["id"], { @@ -417,3 +419,22 @@ def handle_config_update( changes.pop("type") manager.config.update(**changes) connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) +@websocket_api.async_response +async def handle_subscribe_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to backup events.""" + + def on_event(event: ManagerStateEvent) -> None: + connection.send_message(websocket_api.event_message(msg["id"], event)) + + manager = hass.data[DATA_MANAGER] + on_event(manager.last_event) + connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 422dc4be567..bacd32fa77e 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -93,7 +93,7 @@ } }, "preset1": { - "name": "Favourite 1", + "name": "Favorite 1", "state_attributes": { "event_type": { "state": { @@ -107,7 +107,7 @@ } }, "preset2": { - "name": "Favourite 2", + "name": "Favorite 2", "state_attributes": { "event_type": { "state": { @@ -121,7 +121,7 @@ } }, "preset3": { - "name": "Favourite 3", + "name": "Favorite 3", "state_attributes": { "event_type": { "state": { @@ -135,7 +135,7 @@ } }, "preset4": { - "name": "Favourite 4", + "name": "Favorite 4", "state_attributes": { "event_type": { "state": { diff --git a/homeassistant/components/bauknecht/__init__.py b/homeassistant/components/bauknecht/__init__.py new file mode 100644 index 00000000000..1e93f1ab0c2 --- /dev/null +++ b/homeassistant/components/bauknecht/__init__.py @@ -0,0 +1 @@ +"""Bauknecht virtual integration.""" diff --git a/homeassistant/components/bauknecht/manifest.json b/homeassistant/components/bauknecht/manifest.json new file mode 100644 index 00000000000..b875d7fbc31 --- /dev/null +++ b/homeassistant/components/bauknecht/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bauknecht", + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" +} diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 0f24eec2178..3e4ffeeea07 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -25,7 +25,6 @@ SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 1f748bd9f63..2cb6a325724 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,12 +5,12 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN +from .const import DOMAIN, SERVICE_SEND_PIN from .coordinator import BlinkConfigEntry SERVICE_SEND_PIN_SCHEMA = vol.Schema( diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 775ca16a12a..eeda91a70a3 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -15,23 +15,31 @@ from bluecurrent_api.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE +from .const import ( + CHARGEPOINT_SETTINGS, + CHARGEPOINT_STATUS, + DOMAIN, + EVSE_ID, + LOGGER, + PLUG_AND_CHARGE, + VALUE, +) type BlueCurrentConfigEntry = ConfigEntry[Connector] -PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" DATA = "data" DELAY = 5 GRID = "GRID" OBJECT = "object" -VALUE_TYPES = ["CH_STATUS"] +VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] async def async_setup_entry( @@ -94,7 +102,7 @@ class Connector: elif object_name in VALUE_TYPES: value_data: dict = message[DATA] evse_id = value_data.pop(EVSE_ID) - self.update_charge_point(evse_id, value_data) + self.update_charge_point(evse_id, object_name, value_data) # gets grid key / values elif GRID in object_name: @@ -106,26 +114,37 @@ class Connector: """Handle incoming chargepoint data.""" await asyncio.gather( *( - self.handle_charge_point( - entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME] - ) + self.handle_charge_point(entry[EVSE_ID], entry) for entry in charge_points_data ), self.client.get_grid_status(charge_points_data[0][EVSE_ID]), ) - async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None: + async def handle_charge_point( + self, evse_id: str, charge_point: dict[str, Any] + ) -> None: """Add the chargepoint and request their data.""" - self.add_charge_point(evse_id, model, name) + self.add_charge_point(evse_id, charge_point) await self.client.get_status(evse_id) - def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + def add_charge_point(self, evse_id: str, charge_point: dict[str, Any]) -> None: """Add a charge point to charge_points.""" - self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + self.charge_points[evse_id] = charge_point - def update_charge_point(self, evse_id: str, data: dict) -> None: + def update_charge_point(self, evse_id: str, update_type: str, data: dict) -> None: """Update the charge point data.""" - self.charge_points[evse_id].update(data) + charge_point = self.charge_points[evse_id] + if update_type == CHARGEPOINT_SETTINGS: + # Update the plug and charge object. The library parses this object to a bool instead of an object. + plug_and_charge = charge_point.get(PLUG_AND_CHARGE) + if plug_and_charge is not None: + plug_and_charge[VALUE] = data[PLUG_AND_CHARGE] + + # Remove the plug and charge object from the data list before updating. + del data[PLUG_AND_CHARGE] + + charge_point.update(data) + self.dispatch_charge_point_update_signal(evse_id) def dispatch_charge_point_update_signal(self, evse_id: str) -> None: diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 008e6efa872..33e0e8b1176 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,3 +8,14 @@ LOGGER = logging.getLogger(__package__) EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +PLUG_AND_CHARGE = "plug_and_charge" +VALUE = "value" +PERMISSION = "permission" +CHARGEPOINT_STATUS = "CH_STATUS" +CHARGEPOINT_SETTINGS = "CH_SETTINGS" +BLOCK = "block" +UNAVAILABLE = "unavailable" +AVAILABLE = "available" +LINKED_CHARGE_CARDS = "linked_charge_cards_only" +PUBLIC_CHARGING = "public_charging" +ACTIVITY = "activity" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index ce936902e91..28d4acbc1d8 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -30,6 +30,17 @@ "stop_charge_session": { "default": "mdi:stop" } + }, + "switch": { + "plug_and_charge": { + "default": "mdi:ev-plug-type2" + }, + "linked_charge_cards": { + "default": "mdi:account-group" + }, + "block": { + "default": "mdi:lock" + } } } } diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index e813b08131c..7c76657eb79 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", "loggers": ["bluecurrent_api"], - "requirements": ["bluecurrent-api==1.2.3"] + "requirements": ["bluecurrent-api==1.3.1"] } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 28eb20fa912..0a99af603cc 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -124,6 +124,17 @@ "reset": { "name": "Reset" } + }, + "switch": { + "plug_and_charge": { + "name": "Plug & Charge" + }, + "linked_charge_cards_only": { + "name": "Linked charging cards only" + }, + "block": { + "name": "Block charge point" + } } } } diff --git a/homeassistant/components/blue_current/switch.py b/homeassistant/components/blue_current/switch.py new file mode 100644 index 00000000000..a0848387901 --- /dev/null +++ b/homeassistant/components/blue_current/switch.py @@ -0,0 +1,169 @@ +"""Support for Blue Current switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PLUG_AND_CHARGE, BlueCurrentConfigEntry, Connector +from .const import ( + AVAILABLE, + BLOCK, + LINKED_CHARGE_CARDS, + PUBLIC_CHARGING, + UNAVAILABLE, + VALUE, +) +from .entity import ChargepointEntity + + +@dataclass(kw_only=True, frozen=True) +class BlueCurrentSwitchEntityDescription(SwitchEntityDescription): + """Describes a Blue Current switch entity.""" + + function: Callable[[Connector, str, bool], Any] + + turn_on_off_fn: Callable[[str, Connector], tuple[bool, bool]] + """Update the switch based on the latest data received from the websocket. The first returned boolean is _attr_is_on, the second one has_value.""" + + +def update_on_value_and_activity( + key: str, evse_id: str, connector: Connector, reverse_is_on: bool = False +) -> tuple[bool, bool]: + """Return the updated state of the switch based on received chargepoint data and activity.""" + + data_object = connector.charge_points[evse_id].get(key) + is_on = data_object[VALUE] if data_object is not None else None + activity = connector.charge_points[evse_id].get("activity") + + if is_on is not None and activity == AVAILABLE: + return is_on if not reverse_is_on else not is_on, True + return False, False + + +def update_block_switch(evse_id: str, connector: Connector) -> tuple[bool, bool]: + """Return the updated data for a block switch.""" + activity = connector.charge_points[evse_id].get("activity") + return activity == UNAVAILABLE, activity in [AVAILABLE, UNAVAILABLE] + + +def update_charge_point( + key: str, evse_id: str, connector: Connector, new_switch_value: bool +) -> None: + """Change charge point data when the state of the switch changes.""" + data_objects = connector.charge_points[evse_id].get(key) + if data_objects is not None: + data_objects[VALUE] = new_switch_value + + +async def set_plug_and_charge(connector: Connector, evse_id: str, value: bool) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_plug_and_charge(evse_id, value) + update_charge_point(PLUG_AND_CHARGE, evse_id, connector, value) + + +async def set_linked_charge_cards( + connector: Connector, evse_id: str, value: bool +) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_linked_charge_cards_only(evse_id, value) + update_charge_point(PUBLIC_CHARGING, evse_id, connector, not value) + + +SWITCHES = ( + BlueCurrentSwitchEntityDescription( + key=PLUG_AND_CHARGE, + translation_key=PLUG_AND_CHARGE, + function=set_plug_and_charge, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity(PLUG_AND_CHARGE, evse_id, connector) + ), + ), + BlueCurrentSwitchEntityDescription( + key=LINKED_CHARGE_CARDS, + translation_key=LINKED_CHARGE_CARDS, + function=set_linked_charge_cards, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity( + PUBLIC_CHARGING, evse_id, connector, reverse_is_on=True + ) + ), + ), + BlueCurrentSwitchEntityDescription( + key=BLOCK, + translation_key=BLOCK, + function=lambda connector, evse_id, value: connector.client.block( + evse_id, value + ), + turn_on_off_fn=update_block_switch, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BlueCurrentConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Blue Current switches.""" + connector = entry.runtime_data + + async_add_entities( + ChargePointSwitch( + connector, + evse_id, + switch, + ) + for evse_id in connector.charge_points + for switch in SWITCHES + ) + + +class ChargePointSwitch(ChargepointEntity, SwitchEntity): + """Base charge point switch.""" + + has_value = True + entity_description: BlueCurrentSwitchEntityDescription + + def __init__( + self, + connector: Connector, + evse_id: str, + switch: BlueCurrentSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(connector, evse_id) + + self.key = switch.key + self.entity_description = switch + self.evse_id = evse_id + self._attr_available = True + self._attr_unique_id = f"{switch.key}_{evse_id}" + + async def call_function(self, value: bool) -> None: + """Call the function to set setting.""" + await self.entity_description.function(self.connector, self.evse_id, value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(True) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(False) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the switch.""" + new_state = self.entity_description.turn_on_off_fn(self.evse_id, self.connector) + self._attr_is_on = new_state[0] + self.has_value = new_state[1] diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index caf5cc7541d..54fb061676d 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==2.0.1"], + "requirements": ["pyblu==2.0.4"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 7abc929fde5..e3428eb9b86 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -388,12 +388,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE scanner = HaScanner(mode, adapter, address) scanner.async_setup() - try: - await scanner.async_start() - except (RuntimeError, ScannerStartError) as err: - raise ConfigEntryNotReady( - f"{adapter_human_name(adapter, address)}: {err}" - ) from err adapters = await manager.async_get_bluetooth_adapters() details = adapters[adapter] if entry.title == address: @@ -401,8 +395,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, title=adapter_title(adapter, details) ) slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS + # Register the scanner before starting so + # any raw advertisement data can be processed entry.async_on_unload(async_register_scanner(hass, scanner, connection_slots=slots)) await async_update_device(hass, entry, adapter, details) + try: + await scanner.async_start() + except (RuntimeError, ScannerStartError) as err: + raise ConfigEntryNotReady( + f"{adapter_human_name(adapter, address)}: {err}" + ) from err entry.async_on_unload(entry.add_update_listener(async_update_listener)) entry.async_on_unload(scanner.async_stop) return True diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 46c5425c730..5f3cb62c158 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -235,10 +235,9 @@ class HomeAssistantBluetoothManager(BluetoothManager): def _async_save_scanner_history(self, scanner: BaseHaScanner) -> None: """Save the scanner history.""" - if isinstance(scanner, BaseHaRemoteScanner): - self.storage.async_set_advertisement_history( - scanner.source, scanner.serialize_discovered_devices() - ) + self.storage.async_set_advertisement_history( + scanner.source, scanner.serialize_discovered_devices() + ) def _async_unregister_scanner( self, scanner: BaseHaScanner, unregister: CALLBACK_TYPE @@ -285,9 +284,8 @@ class HomeAssistantBluetoothManager(BluetoothManager): connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a scanner.""" - if isinstance(scanner, BaseHaRemoteScanner): - if history := self.storage.async_get_advertisement_history(scanner.source): - scanner.restore_discovered_devices(history) + if history := self.storage.async_get_advertisement_history(scanner.source): + scanner.restore_discovered_devices(history) unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f212f4bdc17..c3167b5a704 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,12 +15,12 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.22.3", - "bleak-retry-connector==3.9.0", - "bluetooth-adapters==0.21.4", + "bleak==1.0.1", + "bleak-retry-connector==4.0.2", + "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", - "bluetooth-data-tools==1.28.1", - "dbus-fast==2.43.0", - "habluetooth==3.49.0" + "bluetooth-data-tools==1.28.2", + "dbus-fast==2.44.3", + "habluetooth==5.0.1" ] } diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index d21b11b050f..9022d98bf06 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -39,7 +39,13 @@ def async_setup(hass: HomeAssistant) -> None: def serialize_service_info( service_info: BluetoothServiceInfoBleak, time_diff: float ) -> dict[str, Any]: - """Serialize a BluetoothServiceInfoBleak object.""" + """Serialize a BluetoothServiceInfoBleak object. + + The raw field is included for: + 1. Debugging - to see the actual advertisement packet + 2. Data freshness - manufacturer_data and service_data are aggregated + across multiple advertisements, raw shows the latest packet only + """ return { "name": service_info.name, "address": service_info.address, @@ -57,6 +63,7 @@ def serialize_service_info( "connectable": service_info.connectable, "time": service_info.time + time_diff, "tx_power": service_info.tx_power, + "raw": service_info.raw.hex() if service_info.raw else None, } diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index 7f37476f1bb..c442c921a6b 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import ConfigType from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN -from .services import setup_services +from .services import async_setup_services from .types import BoschAlarmConfigEntry CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up bosch alarm services.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py index 33ec0ae526a..d6f651e8124 100644 --- a/homeassistant/components/bosch_alarm/const.py +++ b/homeassistant/components/bosch_alarm/const.py @@ -6,4 +6,3 @@ CONF_INSTALLER_CODE = "installer_code" CONF_USER_CODE = "user_code" ATTR_DATETIME = "datetime" SERVICE_SET_DATE_TIME = "set_date_time" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py index 5d9a5f5645f..f3292f97ee8 100644 --- a/homeassistant/components/bosch_alarm/services.py +++ b/homeassistant/components/bosch_alarm/services.py @@ -9,12 +9,13 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util -from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME +from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME from .types import BoschAlarmConfigEntry @@ -66,7 +67,8 @@ async def async_set_panel_date(call: ServiceCall) -> None: ) from err -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the bosch alarm integration.""" hass.services.async_register( diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index 76c15a0a5c7..3adccda2ee5 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -95,7 +95,7 @@ "name": "Battery missing" }, "panel_fault_ac_fail": { - "name": "AC Failure" + "name": "AC failure" }, "panel_fault_parameter_crc_fail_in_pif": { "name": "CRC failure in panel configuration" diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index 06ce45cdb3a..e0e2963c340 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -69,12 +69,7 @@ class SHCEntity(SHCBaseEntity): manufacturer=device.manufacturer, model=device.device_model, name=device.name, - via_device=( - DOMAIN, - device.parent_device_id - if device.parent_device_id is not None - else parent_id, - ), + via_device=(DOMAIN, device.root_device_id), ) super().__init__(device=device, parent_id=parent_id, entry_id=entry_id) diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 0c99324efbb..bd2e127df3f 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.91"], + "requirements": ["boschshcpy==0.2.107"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 20250949bcb..a1ee159290a 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -53,8 +53,7 @@ async def async_setup_entry( assert unique_id is not None async_add_entities( - BraviaTVButton(coordinator, unique_id, config_entry.title, description) - for description in BUTTONS + BraviaTVButton(coordinator, unique_id, description) for description in BUTTONS ) @@ -67,11 +66,10 @@ class BraviaTVButton(BraviaTVEntity, ButtonEntity): self, coordinator: BraviaTVCoordinator, unique_id: str, - model: str, description: BraviaTVButtonDescription, ) -> None: """Initialize the button.""" - super().__init__(coordinator, unique_id, model) + super().__init__(coordinator, unique_id) self._attr_unique_id = f"{unique_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 5d775b98180..1a5aa1fddd6 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -79,14 +79,16 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): system_info = await self.client.get_system_info() cid = system_info[ATTR_CID].lower() - title = system_info[ATTR_MODEL] self.device_config[CONF_MAC] = system_info[ATTR_MAC] await self.async_set_unique_id(cid) self._abort_if_unique_id_configured() - return self.async_create_entry(title=title, data=self.device_config) + return self.async_create_entry( + title=f"{system_info['name']} {system_info[ATTR_MODEL]}", + data=self.device_config, + ) async def async_reauth_device(self) -> ConfigFlowResult: """Reauthorize Bravia TV device from config.""" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 039726de94d..41b3923a716 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -81,6 +81,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.use_psk = config_entry.data.get(CONF_USE_PSK, False) self.client_id = config_entry.data.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID) self.nickname = config_entry.data.get(CONF_NICKNAME, NICKNAME_PREFIX) + self.system_info: dict[str, str] = {} self.source: str | None = None self.source_list: list[str] = [] self.source_map: dict[str, dict] = {} @@ -150,6 +151,9 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.is_on = power_status == "active" self.skipped_updates = 0 + if not self.system_info: + self.system_info = await self.client.get_system_info() + if self.is_on is False: return diff --git a/homeassistant/components/braviatv/entity.py b/homeassistant/components/braviatv/entity.py index b4e370f20d2..e1c6260b070 100644 --- a/homeassistant/components/braviatv/entity.py +++ b/homeassistant/components/braviatv/entity.py @@ -12,23 +12,16 @@ class BraviaTVEntity(CoordinatorEntity[BraviaTVCoordinator]): _attr_has_entity_name = True - def __init__( - self, - coordinator: BraviaTVCoordinator, - unique_id: str, - model: str, - ) -> None: + def __init__(self, coordinator: BraviaTVCoordinator, unique_id: str) -> None: """Initialize the entity.""" super().__init__(coordinator) self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, + connections={(CONNECTION_NETWORK_MAC, coordinator.system_info["macAddr"])}, manufacturer=ATTR_MANUFACTURER, - model=model, - name=f"{ATTR_MANUFACTURER} {model}", + model_id=coordinator.system_info["model"], + hw_version=coordinator.system_info["generation"], + serial_number=coordinator.system_info["serial"], ) - if coordinator.client.mac is not None: - self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, coordinator.client.mac) - } diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index fe9c386b060..c4226190ad8 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -34,9 +34,7 @@ async def async_setup_entry( unique_id = config_entry.unique_id assert unique_id is not None - async_add_entities( - [BraviaTVMediaPlayer(coordinator, unique_id, config_entry.title)] - ) + async_add_entities([BraviaTVMediaPlayer(coordinator, unique_id)]) class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index 0611e367445..40f552c9258 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -24,7 +24,7 @@ async def async_setup_entry( unique_id = config_entry.unique_id assert unique_id is not None - async_add_entities([BraviaTVRemote(coordinator, unique_id, config_entry.title)]) + async_add_entities([BraviaTVRemote(coordinator, unique_id)]) class BraviaTVRemote(BraviaTVEntity, RemoteEntity): diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 6c0b34c66f0..943b4863aac 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -8,20 +8,33 @@ from bring_api import Bring from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import ( BringActivityCoordinator, BringConfigEntry, BringCoordinators, BringDataUpdateCoordinator, ) +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Bring! services.""" + + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py index 911c08a835d..f8a10d5c26b 100644 --- a/homeassistant/components/bring/const.py +++ b/homeassistant/components/bring/const.py @@ -7,5 +7,8 @@ DOMAIN = "bring" ATTR_SENDER: Final = "sender" ATTR_ITEM_NAME: Final = "item" ATTR_NOTIFICATION_TYPE: Final = "message" - +ATTR_REACTION: Final = "reaction" +ATTR_ACTIVITY: Final = "uuid" +ATTR_RECEIVER: Final = "publicUserUuid" SERVICE_PUSH_NOTIFICATION = "send_message" +SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction" diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index ea4f4e877bc..288921c41b4 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -35,6 +35,9 @@ "services": { "send_message": { "service": "mdi:cellphone-message" + }, + "send_reaction": { + "service": "mdi:thumb-up" } } } diff --git a/homeassistant/components/bring/services.py b/homeassistant/components/bring/services.py new file mode 100644 index 00000000000..e648fcdd2f1 --- /dev/null +++ b/homeassistant/components/bring/services.py @@ -0,0 +1,110 @@ +"""Actions for Bring! integration.""" + +import logging +from typing import TYPE_CHECKING + +from bring_api import ( + ActivityType, + BringAuthException, + BringNotificationType, + BringRequestException, + ReactionType, +) +import voluptuous as vol + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_registry as er + +from .const import ( + ATTR_ACTIVITY, + ATTR_REACTION, + ATTR_RECEIVER, + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, +) +from .coordinator import BringConfigEntry + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_REACTION): vol.All( + vol.Upper, + vol.Coerce(ReactionType), + ), + } +) + + +def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry: + """Return config entry or raise if not found or not loaded.""" + entry = hass.config_entries.async_get_entry(entry_id) + if TYPE_CHECKING: + assert entry + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + return entry + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Bring! integration.""" + + async def async_send_activity_stream_reaction(call: ServiceCall) -> None: + """Send a reaction in response to recent activity of a list member.""" + + if ( + not (state := hass.states.get(call.data[ATTR_ENTITY_ID])) + or not (entity := er.async_get(hass).async_get(call.data[ATTR_ENTITY_ID])) + or not entity.config_entry_id + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={ + ATTR_ENTITY_ID: call.data[ATTR_ENTITY_ID], + }, + ) + config_entry = get_config_entry(hass, entity.config_entry_id) + + coordinator = config_entry.runtime_data.data + + list_uuid = entity.unique_id.split("_")[1] + + activity = state.attributes[ATTR_EVENT_TYPE] + + reaction: ReactionType = call.data[ATTR_REACTION] + + if not activity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="activity_not_found", + ) + try: + await coordinator.bring.notify( + list_uuid, + BringNotificationType.LIST_ACTIVITY_STREAM_REACTION, + receiver=state.attributes[ATTR_RECEIVER], + activity=state.attributes[ATTR_ACTIVITY], + activity_type=ActivityType(activity.upper()), + reaction=reaction, + ) + except (BringRequestException, BringAuthException) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reaction_request_failed", + ) from e + + hass.services.async_register( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + async_send_activity_stream_reaction, + SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA, + ) diff --git a/homeassistant/components/bring/services.yaml b/homeassistant/components/bring/services.yaml index 98d5c68de13..087b12604a9 100644 --- a/homeassistant/components/bring/services.yaml +++ b/homeassistant/components/bring/services.yaml @@ -21,3 +21,28 @@ send_message: required: false selector: text: +send_reaction: + fields: + entity_id: + required: true + selector: + entity: + filter: + - integration: bring + domain: event + example: event.shopping_list + reaction: + required: true + selector: + select: + options: + - label: 👍🏼 + value: thumbs_up + - label: 🧐 + value: monocle + - label: 🤤 + value: drooling + - label: ❤️ + value: heart + mode: dropdown + example: thumbs_up diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 2c30af5adce..48677d52523 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -144,6 +144,19 @@ }, "notify_request_failed": { "message": "Failed to send push notification for Bring! due to a connection error, try again later" + }, + "reaction_request_failed": { + "message": "Failed to send reaction for Bring! due to a connection error, try again later" + }, + "activity_not_found": { + "message": "Failed to send reaction for Bring! — No recent activity found" + }, + "entity_not_found": { + "message": "Failed to send reaction for Bring! — Unknown entity {entity_id}" + }, + + "entry_not_loaded": { + "message": "The account associated with this Bring! list is either not loaded or disabled in Home Assistant." } }, "services": { @@ -164,6 +177,20 @@ "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" } } + }, + "send_reaction": { + "name": "Send reaction", + "description": "Sends a reaction to a recent activity on a Bring! list by a member of the shared list.", + "fields": { + "entity_id": { + "name": "Activities", + "description": "Select the Bring! activities event entity for reacting to its most recent event" + }, + "reaction": { + "name": "Reaction", + "description": "Type of reaction to send in response." + } + } } }, "selector": { diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index c9b17128b79..602a3693b7b 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -11,6 +11,7 @@ DOMAINS_AND_TYPES = { Platform.SELECT: {"HYS"}, Platform.SENSOR: { "A1", + "A2", "MP1S", "RM4MINI", "RM4PRO", diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index e7d420f0c0e..5323a08d227 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -34,6 +35,24 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="air_quality", device_class=SensorDeviceClass.AQI, ), + SensorEntityDescription( + key="pm10", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm2_5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 8e0a521e182..8fdbb5054a8 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -25,6 +25,7 @@ def get_update_manager(device: BroadlinkDevice[_ApiT]) -> BroadlinkUpdateManager """Return an update manager for a given Broadlink device.""" update_managers: dict[str, type[BroadlinkUpdateManager]] = { "A1": BroadlinkA1UpdateManager, + "A2": BroadlinkA2UpdateManager, "BG1": BroadlinkBG1UpdateManager, "HYS": BroadlinkThermostatUpdateManager, "LB1": BroadlinkLB1UpdateManager, @@ -63,6 +64,7 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]): device.hass, _LOGGER, name=f"{device.name} ({device.api.model} at {device.api.host[0]})", + config_entry=device.config, update_method=self.async_update, update_interval=self.SCAN_INTERVAL, ) @@ -118,6 +120,16 @@ class BroadlinkA1UpdateManager(BroadlinkUpdateManager[blk.a1]): return await self.device.async_request(self.device.api.check_sensors_raw) +class BroadlinkA2UpdateManager(BroadlinkUpdateManager[blk.a2]): + """Manages updates for Broadlink A2 devices.""" + + SCAN_INTERVAL = timedelta(seconds=10) + + async def async_fetch_data(self) -> dict[str, Any]: + """Fetch data from the device.""" + return await self.device.async_request(self.device.api.check_sensors_raw) + + class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]): """Manages updates for Broadlink MP1 devices.""" diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index fa70f3a5dc5..356ba4f01fc 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], - "requirements": ["brother==4.3.1"], + "requirements": ["brother==5.0.1"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 623bfbfef56..a7beb4f8d44 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -2,7 +2,16 @@ import dataclasses -from bsblan import BSBLAN, BSBLANConfig, Device, Info, StaticState +from bsblan import ( + BSBLAN, + BSBLANAuthError, + BSBLANConfig, + BSBLANConnectionError, + BSBLANError, + Device, + Info, + StaticState, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,9 +22,14 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_PASSKEY +from .const import CONF_PASSKEY, DOMAIN from .coordinator import BSBLanUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] @@ -54,10 +68,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo coordinator = BSBLanUpdateCoordinator(hass, entry, bsblan) await coordinator.async_config_entry_first_refresh() - # Fetch all required data concurrently - device = await bsblan.device() - info = await bsblan.info() - static = await bsblan.static_values() + try: + # Fetch all required data sequentially + device = await bsblan.device() + info = await bsblan.info() + static = await bsblan.static_values() + except BSBLANConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_connection_error", + translation_placeholders={"host": entry.data[CONF_HOST]}, + ) from err + except BSBLANAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_auth_error", + ) from err + except BSBLANError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="setup_general_error", + ) from err entry.runtime_data = BSBLanData( client=bsblan, diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index a1d7d6d403a..5f4f67a114a 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any -from bsblan import BSBLAN, BSBLANConfig, BSBLANError +from bsblan import BSBLAN, BSBLANAuthError, BSBLANConfig, BSBLANError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -12,6 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN @@ -21,12 +23,15 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str - port: int - mac: str - passkey: str | None = None - username: str | None = None - password: str | None = None + def __init__(self) -> None: + """Initialize BSBLan flow.""" + self.host: str | None = None + self.port: int = DEFAULT_PORT + self.mac: str | None = None + self.passkey: str | None = None + self.username: str | None = None + self.password: str | None = None + self._auth_required = True async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,25 +46,261 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): self.username = user_input.get(CONF_USERNAME) self.password = user_input.get(CONF_PASSWORD) + return await self._validate_and_create(user_input) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle Zeroconf discovery.""" + + self.host = str(discovery_info.ip_address) + self.port = discovery_info.port or DEFAULT_PORT + + # Get MAC from properties + self.mac = discovery_info.properties.get("mac") + + # If MAC was found in zeroconf, use it immediately + if self.mac: + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + else: + # MAC not available from zeroconf - check for existing host/port first + self._async_abort_entries_match( + {CONF_HOST: self.host, CONF_PORT: self.port} + ) + + # Try to get device info without authentication to minimize discovery popup + config = BSBLANConfig(host=self.host, port=self.port) + session = async_get_clientsession(self.hass) + bsblan = BSBLAN(config, session) + try: + device = await bsblan.device() + except BSBLANError: + # Device requires authentication - proceed to discovery confirm + self.mac = None + else: + self.mac = device.MAC + + # Got MAC without auth - set unique ID and check for existing device + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + # No auth needed, so we can proceed to a confirmation step without fields + self._auth_required = False + + # Proceed to get credentials + self.context["title_placeholders"] = {"name": f"BSBLAN {self.host}"} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle getting credentials for discovered device.""" + if user_input is None: + data_schema = vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ) + if not self._auth_required: + data_schema = vol.Schema({}) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=data_schema, + description_placeholders={"host": str(self.host)}, + ) + + if not self._auth_required: + return self._async_create_entry() + + self.passkey = user_input.get(CONF_PASSKEY) + self.username = user_input.get(CONF_USERNAME) + self.password = user_input.get(CONF_PASSWORD) + + return await self._validate_and_create(user_input, is_discovery=True) + + async def _validate_and_create( + self, user_input: dict[str, Any], is_discovery: bool = False + ) -> ConfigFlowResult: + """Validate device connection and create entry.""" try: await self._get_bsblan_info() + except BSBLANAuthError: + if is_discovery: + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors={"base": "invalid_auth"}, + description_placeholders={"host": str(self.host)}, + ) + return self._show_setup_form({"base": "invalid_auth"}, user_input) except BSBLANError: + if is_discovery: + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors={"base": "cannot_connect"}, + description_placeholders={"host": str(self.host)}, + ) return self._show_setup_form({"base": "cannot_connect"}) return self._async_create_entry() + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + 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 flow.""" + existing_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert existing_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, + } + ), + ) + + # 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) + + try: + await self._get_bsblan_info(raise_on_progress=False, is_reauth=True) + except BSBLANAuthError: + 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"}, + ) + except BSBLANError: + 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"}, + ) + + # Update only the fields that were provided by the user + return self.async_update_reload_and_abort( + existing_entry, data_updates=user_input, reason="reauth_successful" + ) + @callback - def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: + 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): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_PASSKEY): str, - vol.Optional(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, + 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, } ), errors=errors or {}, @@ -67,6 +308,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): @callback def _async_create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" return self.async_create_entry( title=format_mac(self.mac), data={ @@ -78,8 +320,12 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None: - """Get device information from an BSBLAN device.""" + async def _get_bsblan_info( + self, + raise_on_progress: bool = True, + is_reauth: bool = False, + ) -> None: + """Get device information from a BSBLAN device.""" config = BSBLANConfig( host=self.host, passkey=self.passkey, @@ -90,14 +336,23 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass) bsblan = BSBLAN(config, session) device = await bsblan.device() - self.mac = device.MAC + retrieved_mac = device.MAC - await self.async_set_unique_id( - format_mac(self.mac), raise_on_progress=raise_on_progress - ) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.host, - CONF_PORT: self.port, - } - ) + # Handle unique ID assignment based on whether MAC was available from zeroconf + if not self.mac: + # MAC wasn't available from zeroconf, now we have it from API + self.mac = retrieved_mac + await self.async_set_unique_id( + format_mac(self.mac), raise_on_progress=raise_on_progress + ) + + # Skip unique_id configuration check during reauth to prevent "already_configured" abort + if not is_reauth: + # Always allow updating host/port for both user and discovery flows + # This ensures connectivity is maintained when devices change IP addresses + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index 5c5e23efa8a..38a19dba8ea 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -4,11 +4,19 @@ from dataclasses import dataclass from datetime import timedelta from random import randint -from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State +from bsblan import ( + BSBLAN, + BSBLANAuthError, + BSBLANConnectionError, + HotWaterState, + Sensor, + State, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, SCAN_INTERVAL @@ -62,6 +70,10 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): state = await self.client.state() sensor = await self.client.sensor() dhw = await self.client.hot_water_state() + except BSBLANAuthError as err: + raise ConfigEntryAuthFailed( + "Authentication failed for BSB-Lan device" + ) from err except BSBLANConnectionError as err: host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown" raise UpdateFailed( diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 8ea339f76c4..c5245524e28 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,11 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==2.1.0"] + "requirements": ["python-bsblan==2.1.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "bsb-lan*" + } + ] } diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index 6a6784a4542..7f3f7f48afc 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -20,6 +20,8 @@ from . import BSBLanConfigEntry, BSBLanData from .coordinator import BSBLanCoordinatorData from .entity import BSBLanEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class BSBLanSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 93562763999..b27be62e052 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -13,16 +13,50 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your BSB-Lan device." + "host": "The hostname or IP address of your BSB-Lan device.", + "port": "The port number of your BSB-Lan device.", + "passkey": "The passkey for your BSB-Lan device.", + "username": "The username for your BSB-Lan device.", + "password": "The password for your BSB-Lan device." + } + }, + "discovery_confirm": { + "title": "BSB-Lan device discovered", + "description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.", + "data": { + "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The BSB-Lan integration needs to re-authenticate with {name}", + "data": { + "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]" } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "exceptions": { @@ -37,6 +71,15 @@ }, "set_operation_mode_error": { "message": "An error occurred while setting the operation mode" + }, + "setup_connection_error": { + "message": "Failed to retrieve static device data from BSB-Lan device at {host}" + }, + "setup_auth_error": { + "message": "Authentication failed while retrieving static device data" + }, + "setup_general_error": { + "message": "An unknown error occurred while retrieving static device data" } }, "entity": { diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index 2ef29541f40..6ab88c48c46 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -45,7 +45,7 @@ class BTHomePassiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class BTHomePassiveBluetoothDataProcessor[_T]( diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index 6d194714c64..b9e01051419 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -70,7 +70,7 @@ def get_event_classes_by_device_id(hass: HomeAssistant, device_id: str) -> list[ bthome_config_entry = next( entry for entry in config_entries if entry and entry.domain == DOMAIN ) - return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) # type: ignore[no-any-return] def get_event_types_by_event_class(event_class: str) -> set[str]: diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 7025929abd8..dbabad96041 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -72,7 +72,7 @@ SENSOR_DESCRIPTIONS = { key=str(BTHomeExtendedSensorDeviceClass.CHANNEL), state_class=SensorStateClass.MEASUREMENT, ), - # Conductivity (µS/cm) + # Conductivity (μS/cm) ( BTHomeSensorDeviceClass.CONDUCTIVITY, Units.CONDUCTIVITY, @@ -215,7 +215,7 @@ SENSOR_DESCRIPTIONS = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - # PM10 (µg/m3) + # PM10 (μg/m3) ( BTHomeSensorDeviceClass.PM10, Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -225,7 +225,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), - # PM2.5 (µg/m3) + # PM2.5 (μg/m3) ( BTHomeSensorDeviceClass.PM25, Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -318,7 +318,7 @@ SENSOR_DESCRIPTIONS = { key=str(BTHomeSensorDeviceClass.UV_INDEX), state_class=SensorStateClass.MEASUREMENT, ), - # Volatile organic Compounds (VOC) (µg/m3) + # Volatile organic Compounds (VOC) (μg/m3) ( BTHomeSensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 586543de129..b32e630ef5c 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="windazimuth", translation_key="windazimuth", native_unit_of_measurement=DEGREE, - icon="mdi:compass-outline", device_class=SensorDeviceClass.WIND_DIRECTION, state_class=SensorStateClass.MEASUREMENT_ANGLE, ), diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index f552e9ae12b..49a70ba9ffa 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -25,7 +25,7 @@ "services": { "press": { "name": "Press", - "description": "Press the button entity." + "description": "Presses a button entity." } } } diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index d0e0bd0b1d0..3b201c79e0c 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.6.0", "icalendar==6.1.0"] + "requirements": ["caldav==1.6.0", "icalendar==6.3.1"] } diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index e8f92c0b25c..75e537e457c 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -107,7 +107,7 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): """Return the state of the device.""" media_state = self.client.play_state.state if media_state == "NETWORK": - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF if self.client.state.power: if media_state == "play": return MediaPlayerState.PLAYING diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 790579d6a73..4a244ce3530 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -255,7 +255,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ) entity_description: ClimateEntityDescription - _attr_current_humidity: int | None = None + _attr_current_humidity: float | None = None _attr_current_temperature: float | None = None _attr_fan_mode: str | None _attr_fan_modes: list[str] | None diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index fb5ba4f1796..8ef1b984ff9 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -100,16 +100,10 @@ set_hvac_mode: fields: hvac_mode: selector: - select: - options: - - "off" - - "auto" - - "cool" - - "dry" - - "fan_only" - - "heat_cool" - - "heat" - translation_key: hvac_mode + state: + hide_states: + - unavailable + - unknown set_swing_mode: target: entity: diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 5bd40eb5b83..26fda1a405f 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -6,12 +6,16 @@ import asyncio from collections.abc import Callable from contextlib import suppress from datetime import datetime, timedelta -from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any import aiohttp -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import AlexaApiError, Cloud +from hass_nabucasa.alexa_api import ( + AlexaAccessTokenDetails, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) from yarl import URL from homeassistant.components import persistent_notification @@ -146,7 +150,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud_user = cloud_user self._prefs = prefs self._cloud = cloud - self._token = None + self._token: str | None = None self._token_valid: datetime | None = None self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None @@ -318,32 +322,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): async def async_get_access_token(self) -> str | None: """Get an access token.""" + details: AlexaAccessTokenDetails | None if self._token_valid is not None and self._token_valid > utcnow(): return self._token - resp = await cloud_api.async_alexa_access_token(self._cloud) - body = await resp.json() + try: + details = await self._cloud.alexa_api.access_token() + except AlexaApiNeedsRelinkError as exception: + if self.should_report_state: + persistent_notification.async_create( + self.hass, + ( + "There was an error reporting state to Alexa" + f" ({exception.reason}). Please re-link your Alexa skill via" + " the Alexa app to continue using it." + ), + "Alexa state reporting disabled", + "cloud_alexa_report", + ) + raise alexa_errors.RequireRelink from exception + except (AlexaApiNoTokenError, AlexaApiError) as exception: + raise alexa_errors.NoTokenAvailable from exception - if resp.status == HTTPStatus.BAD_REQUEST: - if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): - if self.should_report_state: - persistent_notification.async_create( - self.hass, - ( - "There was an error reporting state to Alexa" - f" ({body['reason']}). Please re-link your Alexa skill via" - " the Alexa app to continue using it." - ), - "Alexa state reporting disabled", - "cloud_alexa_report", - ) - raise alexa_errors.RequireRelink - - raise alexa_errors.NoTokenAvailable - - self._token = body["access_token"] - self._endpoint = body["event_endpoint"] - self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) + self._token = details["access_token"] + self._endpoint = details["event_endpoint"] + self._token_valid = utcnow() + timedelta(seconds=details["expires_in"]) return self._token async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index f4426eabeed..bca65a68abd 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -10,14 +10,8 @@ import random from typing import Any from aiohttp import ClientError, ClientResponseError -from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError -from hass_nabucasa.cloud_api import ( - FilesHandlerListEntry, - async_files_delete_file, - async_files_list, -) -from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 +from hass_nabucasa import Cloud, CloudApiError, CloudApiNonRetryableError, CloudError +from hass_nabucasa.files import FilesError, StorageType, StoredFile, calculate_b64md5 from homeassistant.components.backup import ( AgentBackup, @@ -186,8 +180,7 @@ class CloudBackupAgent(BackupAgent): """ backup = await self._async_get_backup(backup_id) try: - await async_files_delete_file( - self._cloud, + await self._cloud.files.delete( storage_type=StorageType.BACKUP, filename=backup["Key"], ) @@ -199,12 +192,10 @@ class CloudBackupAgent(BackupAgent): backups = await self._async_list_backups() return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] - async def _async_list_backups(self) -> list[FilesHandlerListEntry]: + async def _async_list_backups(self) -> list[StoredFile]: """List backups.""" try: - backups = await async_files_list( - self._cloud, storage_type=StorageType.BACKUP - ) + backups = await self._cloud.files.list(storage_type=StorageType.BACKUP) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err @@ -220,7 +211,7 @@ class CloudBackupAgent(BackupAgent): backup = await self._async_get_backup(backup_id) return AgentBackup.from_dict(backup["Metadata"]) - async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry: + async def _async_get_backup(self, backup_id: str) -> StoredFile: """Return a backup.""" backups = await self._async_list_backups() diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index a857185f07f..e15ea92dece 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -40,10 +40,11 @@ from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) VALID_REPAIR_TRANSLATION_KEYS = { + "connection_error", "no_subscription", - "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", "subscription_expired", + "warn_bad_custom_domain_configuration", } diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 2b6f45ec474..62496906c9d 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -7,7 +7,7 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import Cloud from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -377,7 +377,7 @@ class CloudGoogleConfig(AbstractConfig): return HTTPStatus.OK async with self._sync_entities_lock: - resp = await cloud_api.async_google_actions_request_sync(self._cloud) + resp = await self._cloud.google_report_state.request_sync() return resp.status async def async_connect_agent_user(self, agent_user_id: str) -> None: diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 998f3fcd5bc..49e4af9e3e5 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -71,7 +71,7 @@ _CLOUD_ERRORS: dict[ ] = { TimeoutError: ( HTTPStatus.BAD_GATEWAY, - "Unable to reach the Home Assistant cloud.", + "Unable to reach the Home Assistant Cloud.", ), aiohttp.ClientError: ( HTTPStatus.INTERNAL_SERVER_ERROR, diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index b5c73e08f3e..a0f88b3a558 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==0.103.0"], + "requirements": ["hass-nabucasa==1.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py index fe418fb5340..ed66cb8244f 100644 --- a/homeassistant/components/cloud/repairs.py +++ b/homeassistant/components/cloud/repairs.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio -from typing import Any +from hass_nabucasa.payments_api import SubscriptionInfo import voluptuous as vol from homeassistant.components.repairs import ( @@ -26,7 +26,7 @@ MAX_RETRIES = 60 # This allows for 10 minutes of retries @callback def async_manage_legacy_subscription_issue( hass: HomeAssistant, - subscription_info: dict[str, Any], + subscription_info: SubscriptionInfo, ) -> None: """Manage the legacy subscription issue. @@ -50,7 +50,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" wait_task: asyncio.Task | None = None - _data: dict[str, Any] | None = None + _data: SubscriptionInfo | None = None async def async_step_init(self, _: None = None) -> FlowResult: """Handle the first step of a fix flow.""" diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e7d219ff69e..193d9e3f948 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -62,6 +62,10 @@ } } }, + "connection_error": { + "title": "No connection", + "description": "You do not have a connection to Home Assistant Cloud. Check your network." + }, "no_subscription": { "title": "No subscription detected", "description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}." diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index dc6679a6e40..c1b8fc095c3 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -4,10 +4,13 @@ from __future__ import annotations import asyncio import logging -from typing import Any -from aiohttp.client_exceptions import ClientError -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import ( + Cloud, + MigratePaypalAgreementInfo, + PaymentsApiError, + SubscriptionInfo, +) from .client import CloudClient from .const import REQUEST_TIMEOUT @@ -15,38 +18,30 @@ from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None: +async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo | None: """Fetch the subscription info.""" try: async with asyncio.timeout(REQUEST_TIMEOUT): - return await cloud_api.async_subscription_info(cloud) - except TimeoutError: - _LOGGER.error( - ( - "A timeout of %s was reached while trying to fetch subscription" - " information" - ), - REQUEST_TIMEOUT, - ) - except ClientError: - _LOGGER.error("Failed to fetch subscription information") + return await cloud.payments.subscription_info() + except PaymentsApiError as exception: + _LOGGER.error("Failed to fetch subscription information - %s", exception) return None async def async_migrate_paypal_agreement( cloud: Cloud[CloudClient], -) -> dict[str, Any] | None: +) -> MigratePaypalAgreementInfo | None: """Migrate a paypal agreement from legacy.""" try: async with asyncio.timeout(REQUEST_TIMEOUT): - return await cloud_api.async_migrate_paypal_agreement(cloud) + return await cloud.payments.migrate_paypal_agreement() except TimeoutError: _LOGGER.error( "A timeout of %s was reached while trying to start agreement migration", REQUEST_TIMEOUT, ) - except ClientError as exception: + except PaymentsApiError as exception: _LOGGER.error("Failed to start agreement migration - %s", exception) return None diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 85ca599fa87..179f467922f 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -17,6 +17,8 @@ from homeassistant.components.tts import ( PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, TextToSpeechEntity, + TTSAudioRequest, + TTSAudioResponse, TtsAudioType, Voice, ) @@ -332,7 +334,7 @@ class CloudTTSEntity(TextToSpeechEntity): def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { - ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3.value, } @property @@ -433,6 +435,29 @@ class CloudTTSEntity(TextToSpeechEntity): return (options[ATTR_AUDIO_OUTPUT], data) + async def async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: + """Generate speech from an incoming message.""" + data_gen = self.cloud.voice.process_tts_stream( + text_stream=request.message_gen, + **_prepare_voice_args( + hass=self.hass, + language=request.language, + voice=request.options.get( + ATTR_VOICE, + ( + self._voice + if request.language == self._language + else DEFAULT_VOICES[request.language] + ), + ), + gender=request.options.get(ATTR_GENDER), + ), + ) + + return TTSAudioResponse(AudioOutput.WAV.value, data_gen) + class CloudProvider(Provider): """Home Assistant Cloud speech API provider.""" @@ -526,9 +551,11 @@ class CloudProvider(Provider): language=language, voice=options.get( ATTR_VOICE, - self._voice - if language == self._language - else DEFAULT_VOICES[language], + ( + self._voice + if language == self._language + else DEFAULT_VOICES[language] + ), ), gender=options.get(ATTR_GENDER), ), diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 317759f820d..dca7f774331 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -7,22 +7,18 @@ import logging from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError -from coinbase.wallet.client import Client as LegacyClient -from coinbase.wallet.error import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.util import Throttle from .const import ( ACCOUNT_IS_VAULT, API_ACCOUNT_AMOUNT, API_ACCOUNT_AVALIABLE, - API_ACCOUNT_BALANCE, API_ACCOUNT_CURRENCY, - API_ACCOUNT_CURRENCY_CODE, API_ACCOUNT_HOLD, API_ACCOUNT_ID, API_ACCOUNT_NAME, @@ -31,12 +27,9 @@ from .const import ( API_DATA, API_RATES_CURRENCY, API_RESOURCE_TYPE, - API_TYPE_VAULT, API_V3_ACCOUNT_ID, API_V3_TYPE_VAULT, - CONF_CURRENCIES, CONF_EXCHANGE_BASE, - CONF_EXCHANGE_RATES, ) _LOGGER = logging.getLogger(__name__) @@ -51,9 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> """Set up Coinbase from a config entry.""" instance = await hass.async_add_executor_job(create_and_update_instance, entry) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = instance await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -68,68 +58,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: """Create and update a Coinbase Data instance.""" + + # Check if user is using deprecated v2 API credentials if "organizations" not in entry.data[CONF_API_KEY]: - client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) - version = "v2" - else: - client = RESTClient( - api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN] + # Trigger reauthentication to ask user for v3 credentials + raise ConfigEntryAuthFailed( + "Your Coinbase API key appears to be for the deprecated v2 API. " + "Please reconfigure with a new API key created for the v3 API. " + "Visit https://www.coinbase.com/developer-platform to create new credentials." ) - version = "v3" + + client = RESTClient( + api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN] + ) base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD") - instance = CoinbaseData(client, base_rate, version) + instance = CoinbaseData(client, base_rate) instance.update() return instance -async def update_listener( - hass: HomeAssistant, config_entry: CoinbaseConfigEntry -) -> None: - """Handle options update.""" - - await hass.config_entries.async_reload(config_entry.entry_id) - - registry = er.async_get(hass) - entities = er.async_entries_for_config_entry(registry, config_entry.entry_id) - - # Remove orphaned entities - for entity in entities: - currency = entity.unique_id.split("-")[-1] - if ( - "xe" in entity.unique_id - and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) - ) or ( - "wallet" in entity.unique_id - and currency not in config_entry.options.get(CONF_CURRENCIES, []) - ): - registry.async_remove(entity.entity_id) - - -def get_accounts(client, version): +def get_accounts(client): """Handle paginated accounts.""" response = client.get_accounts() - if version == "v2": - accounts = response[API_DATA] - next_starting_after = response.pagination.next_starting_after - - while next_starting_after: - response = client.get_accounts(starting_after=next_starting_after) - accounts += response[API_DATA] - next_starting_after = response.pagination.next_starting_after - - return [ - { - API_ACCOUNT_ID: account[API_ACCOUNT_ID], - API_ACCOUNT_NAME: account[API_ACCOUNT_NAME], - API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][ - API_ACCOUNT_CURRENCY_CODE - ], - API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT], - ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT, - } - for account in accounts - ] - accounts = response[API_ACCOUNTS] while response["has_next"]: response = client.get_accounts(cursor=response["cursor"]) @@ -153,37 +103,28 @@ def get_accounts(client, version): class CoinbaseData: """Get the latest data and update the states.""" - def __init__(self, client, exchange_base, version): + def __init__(self, client, exchange_base): """Init the coinbase data object.""" self.client = client self.accounts = None self.exchange_base = exchange_base self.exchange_rates = None - if version == "v2": - self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] - else: - self.user_id = ( - "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID] - ) - self.api_version = version + self.user_id = ( + "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID] + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from coinbase.""" try: - self.accounts = get_accounts(self.client, self.api_version) - if self.api_version == "v2": - self.exchange_rates = self.client.get_exchange_rates( - currency=self.exchange_base - ) - else: - self.exchange_rates = self.client.get( - "/v2/exchange-rates", - params={API_RATES_CURRENCY: self.exchange_base}, - )[API_DATA] - except (AuthenticationError, HTTPError) as coinbase_error: + self.accounts = get_accounts(self.client) + self.exchange_rates = self.client.get( + "/v2/exchange-rates", + params={API_RATES_CURRENCY: self.exchange_base}, + )[API_DATA] + except HTTPError as coinbase_error: _LOGGER.error( "Authentication error connecting to coinbase: %s", coinbase_error ) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 3234ec29679..6aad3a81d17 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -2,17 +2,20 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError -from coinbase.wallet.client import Client as LegacyClient -from coinbase.wallet.error import AuthenticationError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -45,9 +48,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema( def get_user_from_client(api_key, api_token): """Get the user name from Coinbase API credentials.""" - if "organizations" not in api_key: - client = LegacyClient(api_key, api_token) - return client.get_current_user()["name"] client = RESTClient(api_key=api_key, api_secret=api_token) return client.get_portfolios()["portfolios"][0]["name"] @@ -59,7 +59,7 @@ async def validate_api(hass: HomeAssistant, data): user = await hass.async_add_executor_job( get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN] ) - except (AuthenticationError, HTTPError) as error: + except HTTPError as error: if "api key" in str(error) or " 401 Client Error" in str(error): _LOGGER.debug("Coinbase rejected API credentials due to an invalid API key") raise InvalidKey from error @@ -74,8 +74,8 @@ async def validate_api(hass: HomeAssistant, data): raise InvalidAuth from error except ConnectionError as error: raise CannotConnect from error - api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2" - return {"title": user, "api_version": api_version} + + return {"title": user} async def validate_options( @@ -85,20 +85,17 @@ async def validate_options( client = config_entry.runtime_data.client - accounts = await hass.async_add_executor_job( - get_accounts, client, config_entry.data.get("api_version", "v2") - ) + accounts = await hass.async_add_executor_job(get_accounts, client) accounts_currencies = [ account[API_ACCOUNT_CURRENCY] for account in accounts if not account[ACCOUNT_IS_VAULT] ] - if config_entry.data.get("api_version", "v2") == "v2": - available_rates = await hass.async_add_executor_job(client.get_exchange_rates) - else: - resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates") - available_rates = resp[API_DATA] + + resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates") + available_rates = resp[API_DATA] + if CONF_CURRENCIES in options: for currency in options[CONF_CURRENCIES]: if currency not in accounts_currencies: @@ -117,6 +114,8 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + reauth_entry: CoinbaseConfigEntry + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: @@ -143,12 +142,63 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - user_input[CONF_API_VERSION] = info["api_version"] return self.async_create_entry(title=info["title"], 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 flow.""" + self.reauth_entry = self._get_reauth_entry() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "account_name": self.reauth_entry.title, + }, + errors=errors, + ) + + try: + await validate_api(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidKey: + errors["base"] = "invalid_auth_key" + except InvalidSecret: + errors["base"] = "invalid_auth_secret" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self.reauth_entry, + data_updates=user_input, + reason="reauth_successful", + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "account_name": self.reauth_entry.title, + }, + errors=errors, + ) + @staticmethod @callback def async_get_options_flow( @@ -158,7 +208,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Coinbase.""" async def async_step_init( diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index be632b5e856..fcd48f9e91d 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/coinbase", "iot_class": "cloud_polling", "loggers": ["coinbase"], - "requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"] + "requirements": ["coinbase-advanced-py==1.2.2"] } diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 578877e7d90..4dfc744b7fa 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -6,6 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,7 +28,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) ATTR_NATIVE_BALANCE = "Balance in native currency" -ATTR_API_VERSION = "API Version" CURRENCY_ICONS = { "BTC": "mdi:currency-btc", @@ -69,11 +69,26 @@ async def async_setup_entry( CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION_DEFAULT ) + # Remove orphaned entities + registry = er.async_get(hass) + existing_entities = er.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + for entity in existing_entities: + currency = entity.unique_id.split("-")[-1] + if ( + "xe" in entity.unique_id + and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) + ) or ( + "wallet" in entity.unique_id + and currency not in config_entry.options.get(CONF_CURRENCIES, []) + ): + registry.async_remove(entity.entity_id) + for currency in desired_currencies: _LOGGER.debug( - "Attempting to set up %s account sensor with %s API", + "Attempting to set up %s account sensor", currency, - instance.api_version, ) if currency not in provided_currencies: _LOGGER.warning( @@ -89,9 +104,8 @@ async def async_setup_entry( if CONF_EXCHANGE_RATES in config_entry.options: for rate in config_entry.options[CONF_EXCHANGE_RATES]: _LOGGER.debug( - "Attempting to set up %s account sensor with %s API", + "Attempting to set up %s exchange rate sensor", rate, - instance.api_version, ) entities.append( ExchangeRateSensor( @@ -146,15 +160,13 @@ class AccountSensor(SensorEntity): """Return the state attributes of the sensor.""" return { ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", - ATTR_API_VERSION: self._coinbase_data.api_version, } def update(self) -> None: """Get the latest state of the sensor.""" _LOGGER.debug( - "Updating %s account sensor with %s API", + "Updating %s account sensor", self._currency, - self._coinbase_data.api_version, ) self._coinbase_data.update() for account in self._coinbase_data.accounts: @@ -210,9 +222,8 @@ class ExchangeRateSensor(SensorEntity): def update(self) -> None: """Get the latest state of the sensor.""" _LOGGER.debug( - "Updating %s rate sensor with %s API", + "Updating %s rate sensor", self._currency, - self._coinbase_data.api_version, ) self._coinbase_data.update() self._attr_native_value = round( diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index 74510731b7a..b0774baf403 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -8,6 +8,14 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "api_token": "API secret" } + }, + "reauth_confirm": { + "title": "Update Coinbase API credentials", + "description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit https://www.coinbase.com/developer-platform to create new credentials for {account_name}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "api_token": "API secret" + } } }, "error": { @@ -18,7 +26,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "Successfully updated credentials" } }, "options": { diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index dfc31b4581b..234241fdeab 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -10,8 +10,6 @@ from typing import Any from jsonpath import jsonpath -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -188,16 +186,7 @@ class CommandSensor(ManualTriggerSensorEntity): self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - elif value is not None: - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index e83339d2c18..96e1cdac3d7 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -6,11 +6,18 @@ from operator import itemgetter import numpy as np import voluptuous as vol -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, +) from homeassistant.const import ( CONF_ATTRIBUTE, + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, + CONF_NAME, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -50,20 +57,23 @@ def datapoints_greater_than_degree(value: dict) -> dict: COMPENSATION_SCHEMA = vol.Schema( { - vol.Required(CONF_SOURCE): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Required(CONF_DATAPOINTS): [ vol.ExactSequence([vol.Coerce(float), vol.Coerce(float)]) ], - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean, - vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean, - vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int, vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All( vol.Coerce(int), vol.Range(min=1, max=7), ), + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int, + vol.Required(CONF_SOURCE): cv.entity_id, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean, } ) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index eae58caa255..4de2a39ec32 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.3.0"] + "requirements": ["numpy==2.3.2"] } diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 95695932540..de025089647 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -7,15 +7,23 @@ from typing import Any import numpy as np -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + CONF_STATE_CLASS, + SensorEntity, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, + CONF_NAME, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import ( @@ -59,24 +67,13 @@ async def async_setup_platform( source: str = conf[CONF_SOURCE] attribute: str | None = conf.get(CONF_ATTRIBUTE) - name = f"{DEFAULT_NAME} {source}" - if attribute is not None: - name = f"{name} {attribute}" + if not (name := conf.get(CONF_NAME)): + name = f"{DEFAULT_NAME} {source}" + if attribute is not None: + name = f"{name} {attribute}" async_add_entities( - [ - CompensationSensor( - conf.get(CONF_UNIQUE_ID), - name, - source, - attribute, - conf[CONF_PRECISION], - conf[CONF_POLYNOMIAL], - conf.get(CONF_UNIT_OF_MEASUREMENT), - conf[CONF_MINIMUM], - conf[CONF_MAXIMUM], - ) - ] + [CompensationSensor(conf.get(CONF_UNIQUE_ID), name, source, attribute, conf)] ) @@ -91,23 +88,27 @@ class CompensationSensor(SensorEntity): name: str, source: str, attribute: str | None, - precision: int, - polynomial: np.poly1d, - unit_of_measurement: str | None, - minimum: tuple[float, float] | None, - maximum: tuple[float, float] | None, + config: dict[str, Any], ) -> None: """Initialize the Compensation sensor.""" + + self._attr_name = name self._source_entity_id = source - self._precision = precision self._source_attribute = attribute - self._attr_native_unit_of_measurement = unit_of_measurement + + self._precision = config[CONF_PRECISION] + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + + polynomial: np.poly1d = config[CONF_POLYNOMIAL] self._poly = polynomial self._coefficients = polynomial.coefficients.tolist() + self._attr_unique_id = unique_id - self._attr_name = name - self._minimum = minimum - self._maximum = maximum + self._minimum = config[CONF_MINIMUM] + self._maximum = config[CONF_MAXIMUM] + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) async def async_added_to_hass(self) -> None: """Handle added to Hass.""" @@ -137,13 +138,40 @@ class CompensationSensor(SensorEntity): """Handle sensor state changes.""" new_state: State | None if (new_state := event.data["new_state"]) is None: + _LOGGER.warning( + "While updating compensation %s, the new_state is None", self.name + ) + self._attr_native_value = None + self.async_write_ha_state() return + if new_state.state == STATE_UNKNOWN: + self._attr_native_value = None + self.async_write_ha_state() + return + + if new_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + if self.native_unit_of_measurement is None and self._source_attribute is None: self._attr_native_unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT ) + if self._attr_device_class is None and ( + device_class := new_state.attributes.get(ATTR_DEVICE_CLASS) + ): + self._attr_device_class = device_class + + if self._attr_state_class is None and ( + state_class := new_state.attributes.get(ATTR_STATE_CLASS) + ): + self._attr_state_class = state_class + if self._source_attribute: value = new_state.attributes.get(self._source_attribute) else: diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d20d4de881f..176c9e2b047 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -137,19 +137,16 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): def _prepare_config_flow_result_json( result: data_entry_flow.FlowResult, - prepare_result_json: Callable[ - [data_entry_flow.FlowResult], data_entry_flow.FlowResult - ], -) -> data_entry_flow.FlowResult: + prepare_result_json: Callable[[data_entry_flow.FlowResult], dict[str, Any]], +) -> dict[str, Any]: """Convert result to JSON.""" if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return prepare_result_json(result) - data = result.copy() - entry: config_entries.ConfigEntry = data["result"] + data = {key: val for key, val in result.items() if key not in ("data", "context")} + entry: config_entries.ConfigEntry = result["result"] # type: ignore[typeddict-item] + # We overwrite the ConfigEntry object with its json representation. data["result"] = entry.as_json_fragment - data.pop("data") - data.pop("context") return data @@ -203,8 +200,8 @@ class ConfigManagerFlowIndexView( def _prepare_result_json( self, result: data_entry_flow.FlowResult - ) -> data_entry_flow.FlowResult: - """Convert result to JSON.""" + ) -> dict[str, Any]: + """Convert result to JSON serializable dict.""" return _prepare_config_flow_result_json(result, super()._prepare_result_json) @@ -228,8 +225,8 @@ class ConfigManagerFlowResourceView( def _prepare_result_json( self, result: data_entry_flow.FlowResult - ) -> data_entry_flow.FlowResult: - """Convert result to JSON.""" + ) -> dict[str, Any]: + """Convert result to JSON serializable dict.""" return _prepare_config_flow_result_json(result, super()._prepare_result_json) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 3d84d6edd69..59216e4a863 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -54,16 +54,20 @@ class Control4RuntimeData: type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] -async def call_c4_api_retry(func, *func_args): # noqa: RET503 +async def call_c4_api_retry(func, *func_args): """Call C4 API function and retry on failure.""" - # Ruff doesn't understand this loop - the exception is always raised after the retries + exc = None for i in range(API_RETRY_TIMES): try: return await func(*func_args) except client_exceptions.ClientError as exception: - _LOGGER.error("Error connecting to Control4 account API: %s", exception) - if i == API_RETRY_TIMES - 1: - raise ConfigEntryNotReady(exception) from exception + _LOGGER.error( + "Try: %d, Error connecting to Control4 account API: %s", + i + 1, + exception, + ) + exc = exception + raise ConfigEntryNotReady(exc) from exc async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: @@ -141,21 +145,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> ui_configuration=ui_configuration, ) - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener( - hass: HomeAssistant, config_entry: Control4ConfigEntry -) -> None: - """Update when config_entry options update.""" - _LOGGER.debug("Config entry was updated, rerunning setup") - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 3ca96ca4e52..9d5df61b513 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -11,7 +11,11 @@ from pyControl4.director import C4Director from pyControl4.error_handling import NotFound, Unauthorized import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -153,7 +157,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Control4.""" async def async_step_init( diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index cf62704b34d..dec26dd3215 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -34,11 +34,13 @@ from .agent_manager import ( from .chat_log import ( AssistantContent, AssistantContentDeltaDict, + Attachment, ChatLog, Content, ConverseError, SystemContent, ToolResultContent, + ToolResultContentDeltaDict, UserContent, async_get_chat_log, ) @@ -51,7 +53,6 @@ from .const import ( DATA_DEFAULT_ENTITY, DOMAIN, HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, SERVICE_PROCESS, SERVICE_RELOAD, ConversationEntityFeature, @@ -61,13 +62,14 @@ from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append +from .util import async_get_result_from_chat_log __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", - "OLD_HOME_ASSISTANT_AGENT", "AssistantContent", "AssistantContentDeltaDict", + "Attachment", "ChatLog", "Content", "ConversationEntity", @@ -78,11 +80,13 @@ __all__ = [ "ConverseError", "SystemContent", "ToolResultContent", + "ToolResultContentDeltaDict", "UserContent", "async_conversation_trace_append", "async_converse", "async_get_agent_info", "async_get_chat_log", + "async_get_result_from_chat_log", "async_set_agent", "async_setup", "async_unset_agent", @@ -115,7 +119,7 @@ CONFIG_SCHEMA = vol.Schema( {cv.string: vol.All(cv.ensure_list, [cv.string])} ) } - ) + ), }, extra=vol.ALLOW_EXTRA, ) @@ -266,17 +270,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) hass.data[DATA_COMPONENT] = entity_component + agent_config = config.get(DOMAIN, {}) await async_setup_default_agent( - hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) - ) - - # Temporary migration. We can remove this in 2024.10 - from homeassistant.components.assist_pipeline import ( # noqa: PLC0415 - async_migrate_engine, - ) - - async_migrate_engine( - hass, "conversation", OLD_HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT + hass, entity_component, config_intents=agent_config.get("intents", {}) ) async def handle_process(service: ServiceCall) -> ServiceResponse: diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 38c0ca8db6b..6203525ac01 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -12,12 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton -from .const import ( - DATA_COMPONENT, - DATA_DEFAULT_ENTITY, - HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, -) +from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT from .entity import ConversationEntity from .models import ( AbstractConversationAgent, @@ -54,7 +49,7 @@ def async_get_agent( hass: HomeAssistant, agent_id: str | None = None ) -> AbstractConversationAgent | ConversationEntity | None: """Get specified agent.""" - if agent_id is None or agent_id in (HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT): + if agent_id is None or agent_id == HOME_ASSISTANT_AGENT: return hass.data[DATA_DEFAULT_ENTITY] if "." in agent_id: diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 6322bdb4435..2f5e3b0cf82 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,7 +8,8 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging -from typing import Any, Literal, TypedDict +from pathlib import Path +from typing import Any, Literal, TypedDict, cast import voluptuous as vol @@ -136,6 +137,21 @@ class UserContent: role: Literal["user"] = field(init=False, default="user") content: str + attachments: list[Attachment] | None = field(default=None) + + +@dataclass(frozen=True) +class Attachment: + """Attachment for a chat message.""" + + media_content_id: str + """Media content ID of the attachment.""" + + mime_type: str + """MIME type of the attachment.""" + + path: Path + """Path to the attachment on disk.""" @dataclass(frozen=True) @@ -145,7 +161,9 @@ class AssistantContent: role: Literal["assistant"] = field(init=False, default="assistant") agent_id: str content: str | None = None + thinking_content: str | None = None tool_calls: list[llm.ToolInput] | None = None + native: Any = None @dataclass(frozen=True) @@ -167,7 +185,18 @@ class AssistantContentDeltaDict(TypedDict, total=False): role: Literal["assistant"] content: str | None + thinking_content: str | None tool_calls: list[llm.ToolInput] | None + native: Any + + +class ToolResultContentDeltaDict(TypedDict, total=False): + """Tool result content.""" + + role: Literal["tool_result"] + tool_call_id: str + tool_name: str + tool_result: JsonObjectType @dataclass @@ -180,6 +209,7 @@ class ChatLog: extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None delta_listener: Callable[[ChatLog, dict], None] | None = None + llm_input_provided_index = 0 @property def continue_conversation(self) -> bool: @@ -214,17 +244,25 @@ class ChatLog: @callback def async_add_assistant_content_without_tools( - self, content: AssistantContent + self, content: AssistantContent | ToolResultContent ) -> None: - """Add assistant content to the log.""" + """Add assistant content to the log. + + Allows assistant content without tool calls or with external tool calls only, + as well as tool results for the external tools. + """ LOGGER.debug("Adding assistant content: %s", content) - if content.tool_calls is not None: - raise ValueError("Tool calls not allowed") + if ( + isinstance(content, AssistantContent) + and content.tool_calls is not None + and any(not tool_call.external for tool_call in content.tool_calls) + ): + raise ValueError("Non-external tool calls not allowed") self.content.append(content) async def async_add_assistant_content( self, - content: AssistantContent, + content: AssistantContent | ToolResultContent, /, tool_call_tasks: dict[str, asyncio.Task] | None = None, ) -> AsyncGenerator[ToolResultContent]: @@ -237,7 +275,11 @@ class ChatLog: LOGGER.debug("Adding assistant content: %s", content) self.content.append(content) - if content.tool_calls is None: + if ( + not isinstance(content, AssistantContent) + or content.tool_calls is None + or all(tool_call.external for tool_call in content.tool_calls) + ): return if self.llm_api is None: @@ -246,13 +288,16 @@ class ChatLog: if tool_call_tasks is None: tool_call_tasks = {} for tool_input in content.tool_calls: - if tool_input.id not in tool_call_tasks: + if tool_input.id not in tool_call_tasks and not tool_input.external: tool_call_tasks[tool_input.id] = self.hass.async_create_task( self.llm_api.async_call_tool(tool_input), name=f"llm_tool_{tool_input.id}", ) for tool_input in content.tool_calls: + if tool_input.external: + continue + LOGGER.debug( "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args ) @@ -275,7 +320,9 @@ class ChatLog: yield response_content async def async_add_delta_content_stream( - self, agent_id: str, stream: AsyncIterable[AssistantContentDeltaDict] + self, + agent_id: str, + stream: AsyncIterable[AssistantContentDeltaDict | ToolResultContentDeltaDict], ) -> AsyncGenerator[AssistantContent | ToolResultContent]: """Stream content into the chat log. @@ -289,6 +336,8 @@ class ChatLog: The keys content and tool_calls will be concatenated if they appear multiple times. """ current_content = "" + current_thinking_content = "" + current_native: Any = None current_tool_calls: list[llm.ToolInput] = [] tool_call_tasks: dict[str, asyncio.Task] = {} @@ -297,34 +346,54 @@ class ChatLog: # Indicates update to current message if "role" not in delta: - if delta_content := delta.get("content"): + # ToolResultContentDeltaDict will always have a role + assistant_delta = cast(AssistantContentDeltaDict, delta) + if delta_content := assistant_delta.get("content"): current_content += delta_content - if delta_tool_calls := delta.get("tool_calls"): - if self.llm_api is None: - raise ValueError("No LLM API configured") + if delta_thinking_content := assistant_delta.get("thinking_content"): + current_thinking_content += delta_thinking_content + if delta_native := assistant_delta.get("native"): + if current_native is not None: + raise RuntimeError( + "Native content already set, cannot overwrite" + ) + current_native = delta_native + if delta_tool_calls := assistant_delta.get("tool_calls"): current_tool_calls += delta_tool_calls # Start processing the tool calls as soon as we know about them for tool_call in delta_tool_calls: - tool_call_tasks[tool_call.id] = self.hass.async_create_task( - self.llm_api.async_call_tool(tool_call), - name=f"llm_tool_{tool_call.id}", - ) + if not tool_call.external: + if self.llm_api is None: + raise ValueError("No LLM API configured") + + tool_call_tasks[tool_call.id] = self.hass.async_create_task( + self.llm_api.async_call_tool(tool_call), + name=f"llm_tool_{tool_call.id}", + ) if self.delta_listener: - self.delta_listener(self, delta) # type: ignore[arg-type] + if filtered_delta := { + k: v for k, v in assistant_delta.items() if k != "native" + }: + # We do not want to send the native content to the listener + # as it is not JSON serializable + self.delta_listener(self, filtered_delta) continue # Starting a new message - - if delta["role"] != "assistant": - raise ValueError(f"Only assistant role expected. Got {delta['role']}") - # Yield the previous message if it has content - if current_content or current_tool_calls: - content = AssistantContent( + if ( + current_content + or current_thinking_content + or current_tool_calls + or current_native + ): + content: AssistantContent | ToolResultContent = AssistantContent( agent_id=agent_id, content=current_content or None, + thinking_content=current_thinking_content or None, tool_calls=current_tool_calls or None, + native=current_native, ) yield content async for tool_result in self.async_add_assistant_content( @@ -333,18 +402,51 @@ class ChatLog: yield tool_result if self.delta_listener: self.delta_listener(self, asdict(tool_result)) + current_content = "" + current_thinking_content = "" + current_native = None + current_tool_calls = [] - current_content = delta.get("content") or "" - current_tool_calls = delta.get("tool_calls") or [] + if delta["role"] == "assistant": + current_content = delta.get("content") or "" + current_thinking_content = delta.get("thinking_content") or "" + current_tool_calls = delta.get("tool_calls") or [] + current_native = delta.get("native") - if self.delta_listener: - self.delta_listener(self, delta) # type: ignore[arg-type] + if self.delta_listener: + if filtered_delta := { + k: v for k, v in delta.items() if k != "native" + }: + self.delta_listener(self, filtered_delta) + elif delta["role"] == "tool_result": + content = ToolResultContent( + agent_id=agent_id, + tool_call_id=delta["tool_call_id"], + tool_name=delta["tool_name"], + tool_result=delta["tool_result"], + ) + yield content + if self.delta_listener: + self.delta_listener(self, asdict(content)) + self.async_add_assistant_content_without_tools(content) + else: + raise ValueError( + "Only assistant and tool_result roles expected." + f" Got {delta['role']}" + ) - if current_content or current_tool_calls: + if ( + current_content + or current_thinking_content + or current_tool_calls + or current_native + ): content = AssistantContent( agent_id=agent_id, content=current_content or None, + thinking_content=current_thinking_content or None, tool_calls=current_tool_calls or None, + native=current_native, ) yield content async for tool_result in self.async_add_assistant_content( @@ -480,6 +582,7 @@ class ChatLog: prompt = "\n".join(prompt_parts) + self.llm_input_provided_index = len(self.content) self.llm_api = llm_api self.extra_system_prompt = extra_system_prompt self.content[0] = SystemContent(content=prompt) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 619a41fd002..266a9f15b83 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "conversation.home_assistant" -OLD_HOME_ASSISTANT_AGENT = "homeassistant" ATTR_TEXT = "text" ATTR_LANGUAGE = "language" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bed4b4c0dd6..4b056ead2c2 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -14,14 +14,19 @@ import re import time from typing import IO, Any, cast -from hassil.expression import Expression, ListReference, Sequence, TextChunk +from hassil.expression import Expression, Group, ListReference, TextChunk +from hassil.fuzzy import FuzzyNgramMatcher, SlotCombinationInfo from hassil.intents import ( + Intent, + IntentData, Intents, SlotList, TextSlotList, TextSlotValue, WildcardSlotList, ) +from hassil.models import MatchEntity +from hassil.ngram import Sqlite3NgramModel from hassil.recognize import ( MISSING_ENTITY, RecognizeResult, @@ -31,7 +36,15 @@ from hassil.recognize import ( from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity from hassil.trie import Trie from hassil.util import merge_dict -from home_assistant_intents import ErrorKey, get_intents, get_languages +from home_assistant_intents import ( + ErrorKey, + FuzzyConfig, + FuzzyLanguageResponses, + get_fuzzy_config, + get_fuzzy_language, + get_intents, + get_languages, +) import yaml from homeassistant import core @@ -76,6 +89,7 @@ TRIGGER_CALLBACK_TYPE = Callable[ ] METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" +METADATA_FUZZY_MATCH = "hass_fuzzy_match" ERROR_SENTINEL = object() @@ -94,6 +108,8 @@ class LanguageIntents: intent_responses: dict[str, Any] error_responses: dict[str, Any] language_variant: str | None + fuzzy_matcher: FuzzyNgramMatcher | None = None + fuzzy_responses: FuzzyLanguageResponses | None = None @dataclass(slots=True) @@ -119,10 +135,13 @@ class IntentMatchingStage(Enum): EXPOSED_ENTITIES_ONLY = auto() """Match against exposed entities only.""" + FUZZY = auto() + """Use fuzzy matching to guess intent.""" + UNEXPOSED_ENTITIES = auto() """Match against unexposed entities in Home Assistant.""" - FUZZY = auto() + UNKNOWN_NAMES = auto() """Capture names that are not known to Home Assistant.""" @@ -241,6 +260,10 @@ class DefaultAgent(ConversationEntity): # LRU cache to avoid unnecessary intent matching self._intent_cache = IntentCache(capacity=128) + # Shared configuration for fuzzy matching + self.fuzzy_matching = True + self._fuzzy_config: FuzzyConfig | None = None + @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" @@ -299,7 +322,7 @@ class DefaultAgent(ConversationEntity): _LOGGER.warning("No intents were loaded for language: %s", language) return None - slot_lists = self._make_slot_lists() + slot_lists = await self._make_slot_lists() intent_context = self._make_intent_context(user_input) if self._exposed_names_trie is not None: @@ -556,6 +579,36 @@ class DefaultAgent(ConversationEntity): # Don't try matching against all entities or doing a fuzzy match return None + # Use fuzzy matching + skip_fuzzy_match = False + if cache_value is not None: + if (cache_value.result is not None) and ( + cache_value.stage == IntentMatchingStage.FUZZY + ): + _LOGGER.debug("Got cached result for fuzzy match") + return cache_value.result + + # Continue with matching, but we know we won't succeed for fuzzy + # match. + skip_fuzzy_match = True + + if (not skip_fuzzy_match) and self.fuzzy_matching: + start_time = time.monotonic() + fuzzy_result = self._recognize_fuzzy(lang_intents, user_input) + + # Update cache + self._intent_cache.put( + cache_key, + IntentCacheValue(result=fuzzy_result, stage=IntentMatchingStage.FUZZY), + ) + + _LOGGER.debug( + "Did fuzzy match in %s second(s)", time.monotonic() - start_time + ) + + if fuzzy_result is not None: + return fuzzy_result + # Try again with all entities (including unexposed) skip_unexposed_entities_match = False if cache_value is not None: @@ -601,102 +654,160 @@ class DefaultAgent(ConversationEntity): # This should fail the intent handling phase (async_match_targets). return strict_result - # Try again with missing entities enabled - skip_fuzzy_match = False + # Check unknown names + skip_unknown_names = False if cache_value is not None: if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.FUZZY + cache_value.stage == IntentMatchingStage.UNKNOWN_NAMES ): - _LOGGER.debug("Got cached result for fuzzy match") + _LOGGER.debug("Got cached result for unknown names") return cache_value.result - # We know we won't succeed for fuzzy matching. - skip_fuzzy_match = True + skip_unknown_names = True maybe_result: RecognizeResult | None = None - if not skip_fuzzy_match: + if not skip_unknown_names: start_time = time.monotonic() - best_num_matched_entities = 0 - best_num_unmatched_entities = 0 - best_num_unmatched_ranges = 0 - for result in recognize_all( - user_input.text, - lang_intents.intents, - slot_lists=slot_lists, - intent_context=intent_context, - allow_unmatched_entities=True, - ): - if result.text_chunks_matched < 1: - # Skip results that don't match any literal text - continue - - # Don't count missing entities that couldn't be filled from context - num_matched_entities = 0 - for matched_entity in result.entities_list: - if matched_entity.name not in result.unmatched_entities: - num_matched_entities += 1 - - num_unmatched_entities = 0 - num_unmatched_ranges = 0 - for unmatched_entity in result.unmatched_entities_list: - if isinstance(unmatched_entity, UnmatchedTextEntity): - if unmatched_entity.text != MISSING_ENTITY: - num_unmatched_entities += 1 - elif isinstance(unmatched_entity, UnmatchedRangeEntity): - num_unmatched_ranges += 1 - num_unmatched_entities += 1 - else: - num_unmatched_entities += 1 - - if ( - (maybe_result is None) # first result - or ( - # More literal text matched - result.text_chunks_matched > maybe_result.text_chunks_matched - ) - or ( - # More entities matched - num_matched_entities > best_num_matched_entities - ) - or ( - # Fewer unmatched entities - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities < best_num_unmatched_entities) - ) - or ( - # Prefer unmatched ranges - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges > best_num_unmatched_ranges) - ) - or ( - # Prefer match failures with entities - (result.text_chunks_matched == maybe_result.text_chunks_matched) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges == best_num_unmatched_ranges) - and ( - ("name" in result.entities) - or ("name" in result.unmatched_entities) - ) - ) - ): - maybe_result = result - best_num_matched_entities = num_matched_entities - best_num_unmatched_entities = num_unmatched_entities - best_num_unmatched_ranges = num_unmatched_ranges + maybe_result = self._recognize_unknown_names( + lang_intents, user_input, slot_lists, intent_context + ) # Update cache self._intent_cache.put( cache_key, - IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY), + IntentCacheValue( + result=maybe_result, stage=IntentMatchingStage.UNKNOWN_NAMES + ), ) _LOGGER.debug( - "Did fuzzy match in %s second(s)", time.monotonic() - start_time + "Did unknown names match in %s second(s)", time.monotonic() - start_time ) return maybe_result + def _recognize_fuzzy( + self, lang_intents: LanguageIntents, user_input: ConversationInput + ) -> RecognizeResult | None: + """Return fuzzy recognition from hassil.""" + if lang_intents.fuzzy_matcher is None: + return None + + fuzzy_result = lang_intents.fuzzy_matcher.match(user_input.text) + if fuzzy_result is None: + return None + + response = "default" + if lang_intents.fuzzy_responses: + domain = "" # no domain + if "name" in fuzzy_result.slots: + domain = fuzzy_result.name_domain + elif "domain" in fuzzy_result.slots: + domain = fuzzy_result.slots["domain"].value + + slot_combo = tuple(sorted(fuzzy_result.slots)) + if ( + intent_responses := lang_intents.fuzzy_responses.get( + fuzzy_result.intent_name + ) + ) and (combo_responses := intent_responses.get(slot_combo)): + response = combo_responses.get(domain, response) + + entities = [ + MatchEntity(name=slot_name, value=slot_value.value, text=slot_value.text) + for slot_name, slot_value in fuzzy_result.slots.items() + ] + + return RecognizeResult( + intent=Intent(name=fuzzy_result.intent_name), + intent_data=IntentData(sentence_texts=[]), + intent_metadata={METADATA_FUZZY_MATCH: True}, + entities={entity.name: entity for entity in entities}, + entities_list=entities, + response=response, + ) + + def _recognize_unknown_names( + self, + lang_intents: LanguageIntents, + user_input: ConversationInput, + slot_lists: dict[str, SlotList], + intent_context: dict[str, Any] | None, + ) -> RecognizeResult | None: + """Return result with unknown names for an error message.""" + maybe_result: RecognizeResult | None = None + + best_num_matched_entities = 0 + best_num_unmatched_entities = 0 + best_num_unmatched_ranges = 0 + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if result.text_chunks_matched < 1: + # Skip results that don't match any literal text + continue + + # Don't count missing entities that couldn't be filled from context + num_matched_entities = 0 + for matched_entity in result.entities_list: + if matched_entity.name not in result.unmatched_entities: + num_matched_entities += 1 + + num_unmatched_entities = 0 + num_unmatched_ranges = 0 + for unmatched_entity in result.unmatched_entities_list: + if isinstance(unmatched_entity, UnmatchedTextEntity): + if unmatched_entity.text != MISSING_ENTITY: + num_unmatched_entities += 1 + elif isinstance(unmatched_entity, UnmatchedRangeEntity): + num_unmatched_ranges += 1 + num_unmatched_entities += 1 + else: + num_unmatched_entities += 1 + + if ( + (maybe_result is None) # first result + or ( + # More literal text matched + result.text_chunks_matched > maybe_result.text_chunks_matched + ) + or ( + # More entities matched + num_matched_entities > best_num_matched_entities + ) + or ( + # Fewer unmatched entities + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities < best_num_unmatched_entities) + ) + or ( + # Prefer unmatched ranges + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges > best_num_unmatched_ranges) + ) + or ( + # Prefer match failures with entities + (result.text_chunks_matched == maybe_result.text_chunks_matched) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) + and ( + ("name" in result.entities) + or ("name" in result.unmatched_entities) + ) + ) + ): + maybe_result = result + best_num_matched_entities = num_matched_entities + best_num_unmatched_entities = num_unmatched_entities + best_num_unmatched_ranges = num_unmatched_ranges + + return maybe_result + def _get_unexposed_entity_names(self, text: str) -> TextSlotList: """Get filtered slot list with unexposed entity names in Home Assistant.""" if self._unexposed_names_trie is None: @@ -851,7 +962,7 @@ class DefaultAgent(ConversationEntity): if lang_intents is None: return - self._make_slot_lists() + await self._make_slot_lists() async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None: """Load all intents of a language with lock.""" @@ -1002,12 +1113,85 @@ class DefaultAgent(ConversationEntity): intent_responses = responses_dict.get("intents", {}) error_responses = responses_dict.get("errors", {}) + if not self.fuzzy_matching: + _LOGGER.debug("Fuzzy matching is disabled") + return LanguageIntents( + intents, + intents_dict, + intent_responses, + error_responses, + language_variant, + ) + + # Load fuzzy + fuzzy_info = get_fuzzy_language(language_variant, json_load=json_load) + if fuzzy_info is None: + _LOGGER.debug( + "Fuzzy matching not available for language: %s", language_variant + ) + return LanguageIntents( + intents, + intents_dict, + intent_responses, + error_responses, + language_variant, + ) + + if self._fuzzy_config is None: + # Load shared config + self._fuzzy_config = get_fuzzy_config(json_load=json_load) + _LOGGER.debug("Loaded shared fuzzy matching config") + + assert self._fuzzy_config is not None + + fuzzy_matcher: FuzzyNgramMatcher | None = None + fuzzy_responses: FuzzyLanguageResponses | None = None + + start_time = time.monotonic() + fuzzy_responses = fuzzy_info.responses + fuzzy_matcher = FuzzyNgramMatcher( + intents=intents, + intent_models={ + intent_name: Sqlite3NgramModel( + order=fuzzy_model.order, + words={ + word: str(word_id) + for word, word_id in fuzzy_model.words.items() + }, + database_path=fuzzy_model.database_path, + ) + for intent_name, fuzzy_model in fuzzy_info.ngram_models.items() + }, + intent_slot_list_names=self._fuzzy_config.slot_list_names, + slot_combinations={ + intent_name: { + combo_key: [ + SlotCombinationInfo( + name_domains=(set(name_domains) if name_domains else None) + ) + ] + for combo_key, name_domains in intent_combos.items() + } + for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items() + }, + domain_keywords=fuzzy_info.domain_keywords, + stop_words=fuzzy_info.stop_words, + ) + _LOGGER.debug( + "Loaded fuzzy matcher in %s second(s): language=%s, intents=%s", + time.monotonic() - start_time, + language_variant, + sorted(fuzzy_matcher.intent_models.keys()), + ) + return LanguageIntents( intents, intents_dict, intent_responses, error_responses, language_variant, + fuzzy_matcher=fuzzy_matcher, + fuzzy_responses=fuzzy_responses, ) @core.callback @@ -1027,8 +1211,7 @@ class DefaultAgent(ConversationEntity): # Slot lists have changed, so we must clear the cache self._intent_cache.clear() - @core.callback - def _make_slot_lists(self) -> dict[str, SlotList]: + async def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists @@ -1089,6 +1272,10 @@ class DefaultAgent(ConversationEntity): "floor": TextSlotList.from_tuples(floor_names, allow_template=False), } + # Reload fuzzy matchers with new slot lists + if self.fuzzy_matching: + await self.hass.async_add_executor_job(self._load_fuzzy_matchers) + self._listen_clear_slot_list() _LOGGER.debug( @@ -1098,6 +1285,25 @@ class DefaultAgent(ConversationEntity): return self._slot_lists + def _load_fuzzy_matchers(self) -> None: + """Reload fuzzy matchers for all loaded languages.""" + for lang_intents in self._lang_intents.values(): + if (not isinstance(lang_intents, LanguageIntents)) or ( + lang_intents.fuzzy_matcher is None + ): + continue + + lang_matcher = lang_intents.fuzzy_matcher + lang_intents.fuzzy_matcher = FuzzyNgramMatcher( + intents=lang_matcher.intents, + intent_models=lang_matcher.intent_models, + intent_slot_list_names=lang_matcher.intent_slot_list_names, + slot_combinations=lang_matcher.slot_combinations, + domain_keywords=lang_matcher.domain_keywords, + stop_words=lang_matcher.stop_words, + slot_lists=self._slot_lists, + ) + def _make_intent_context( self, user_input: ConversationInput ) -> dict[str, Any] | None: @@ -1183,7 +1389,7 @@ class DefaultAgent(ConversationEntity): for trigger_intent in trigger_intents.intents.values(): for intent_data in trigger_intent.data: for sentence in intent_data.sentences: - _collect_list_references(sentence, wildcard_names) + _collect_list_references(sentence.expression, wildcard_names) for wildcard_name in wildcard_names: trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) @@ -1520,11 +1726,9 @@ def _get_match_error_response( def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" - if isinstance(expression, Sequence): - seq: Sequence = expression - for item in seq.items: + if isinstance(expression, Group): + for item in expression.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} - list_ref: ListReference = expression - list_names.add(list_ref.slot_name) + list_names.add(expression.slot_name) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index efcdcb8d69b..290e3aab955 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -26,7 +26,11 @@ from .agent_manager import ( get_agent_manager, ) from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY -from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE +from .default_agent import ( + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, + METADATA_FUZZY_MATCH, +) from .entity import ConversationEntity from .models import ConversationInput @@ -240,6 +244,8 @@ async def websocket_hass_agent_debug( "sentence_template": "", # When match is incomplete, this will contain the best slot guesses "unmatched_slots": _get_unmatched_slots(intent_result), + # True if match was not exact + "fuzzy_match": False, } if successful_match: @@ -251,16 +257,19 @@ async def websocket_hass_agent_debug( if intent_result.intent_sentence is not None: result_dict["sentence_template"] = intent_result.intent_sentence.text - # Inspect metadata to determine if this matched a custom sentence - if intent_result.intent_metadata and intent_result.intent_metadata.get( - METADATA_CUSTOM_SENTENCE - ): - result_dict["source"] = "custom" - result_dict["file"] = intent_result.intent_metadata.get( - METADATA_CUSTOM_FILE + if intent_result.intent_metadata: + # Inspect metadata to determine if this matched a custom sentence + if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE): + result_dict["source"] = "custom" + result_dict["file"] = intent_result.intent_metadata.get( + METADATA_CUSTOM_FILE + ) + else: + result_dict["source"] = "builtin" + + result_dict["fuzzy_match"] = intent_result.intent_metadata.get( + METADATA_FUZZY_MATCH, False ) - else: - result_dict["source"] = "builtin" result_dicts.append(result_dict) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ad0a4c96102..e7d096212ba 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": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.23"] + "requirements": ["hassil==3.1.0", "home-assistant-intents==2025.7.30"] } diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py new file mode 100644 index 00000000000..04a5a420279 --- /dev/null +++ b/homeassistant/components/conversation/util.py @@ -0,0 +1,47 @@ +"""Utility functions for conversation integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent, llm + +from .chat_log import AssistantContent, ChatLog, ToolResultContent +from .models import ConversationInput, ConversationResult + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_result_from_chat_log( + user_input: ConversationInput, chat_log: ChatLog +) -> ConversationResult: + """Get the result from the chat log.""" + tool_results = [ + content.tool_result + for content in chat_log.content[chat_log.llm_input_provided_index :] + if isinstance(content, ToolResultContent) + and isinstance(content.tool_result, llm.IntentResponseDict) + ] + + if tool_results: + intent_response = tool_results[-1].original + else: + intent_response = intent.IntentResponse(language=user_input.language) + + if not isinstance((last_content := chat_log.content[-1]), AssistantContent): + _LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + last_content, + ) + raise HomeAssistantError("Unable to get response") + + intent_response.async_set_speech(last_content.content or "") + + return ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index 5264e47a709..b4cf653f810 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.12.2"] + "requirements": ["cookidoo-api==0.14.0"] } diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 5892ef091d9..18a3e943bbc 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -5,8 +5,9 @@ from pycoolmasternet_async import CoolMasterNet from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .const import CONF_SWING_SUPPORT +from .const import CONF_SWING_SUPPORT, DOMAIN from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] @@ -48,3 +49,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool: """Unload a Coolmaster config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: CoolmasterConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + return not device_entry.identifiers.intersection( + (DOMAIN, unit_id) for unit_id in config_entry.runtime_data.data + ) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 947fe514747..799ff378a35 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.15.0"], + "requirements": ["pydaikin==2.16.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index fa852399b09..219f3afe4e2 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -2,9 +2,10 @@ import logging -from datadog import initialize, statsd +from datadog import DogStatsd, initialize import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -17,14 +18,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType +from . import config_flow as config_flow +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) -CONF_RATE = "rate" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 8125 -DEFAULT_PREFIX = "hass" -DEFAULT_RATE = 1 -DOMAIN = "datadog" +type DatadogConfigEntry = ConfigEntry[DogStatsd] CONFIG_SCHEMA = vol.Schema( { @@ -43,63 +49,87 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Datadog component.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Datadog integration from YAML, initiating config flow import.""" + if DOMAIN not in config: + return True - conf = config[DOMAIN] - host = conf[CONF_HOST] - port = conf[CONF_PORT] - sample_rate = conf[CONF_RATE] - prefix = conf[CONF_PREFIX] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Set up Datadog from a config entry.""" + + data = entry.data + options = entry.options + host = data[CONF_HOST] + port = data[CONF_PORT] + prefix = options[CONF_PREFIX] + sample_rate = options[CONF_RATE] + + statsd_client = DogStatsd( + host=host, port=port, namespace=prefix, disable_telemetry=True + ) + entry.runtime_data = statsd_client initialize(statsd_host=host, statsd_port=port) def logbook_entry_listener(event): - """Listen for logbook entries and send them as events.""" name = event.data.get("name") message = event.data.get("message") - statsd.event( + entry.runtime_data.event( title="Home Assistant", - text=f"%%% \n **{name}** {message} \n %%%", + message=f"%%% \n **{name}** {message} \n %%%", tags=[ f"entity:{event.data.get('entity_id')}", f"domain:{event.data.get('domain')}", ], ) - _LOGGER.debug("Sent event %s", event.data.get("entity_id")) - def state_changed_listener(event): - """Listen for new messages on the bus and sends them to Datadog.""" state = event.data.get("new_state") - if state is None or state.state == STATE_UNKNOWN: return - states = dict(state.attributes) metric = f"{prefix}.{state.domain}" tags = [f"entity:{state.entity_id}"] - for key, value in states.items(): - if isinstance(value, (float, int)): - attribute = f"{metric}.{key.replace(' ', '_')}" + for key, value in state.attributes.items(): + if isinstance(value, (float, int, bool)): value = int(value) if isinstance(value, bool) else value - statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags) + attribute = f"{metric}.{key.replace(' ', '_')}" + entry.runtime_data.gauge( + attribute, value, sample_rate=sample_rate, tags=tags + ) try: value = state_helper.state_as_number(state) + entry.runtime_data.gauge(metric, value, sample_rate=sample_rate, tags=tags) except ValueError: - _LOGGER.debug("Error sending %s: %s (tags: %s)", metric, state.state, tags) - return + pass - statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug("Sent metric %s: %s (tags: %s)", metric, value, tags) - - hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) - hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) + entry.async_on_unload( + hass.bus.async_listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) + ) + entry.async_on_unload( + hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed_listener) + ) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Unload a Datadog config entry.""" + runtime = entry.runtime_data + runtime.flush() + runtime.close_socket() + return True diff --git a/homeassistant/components/datadog/config_flow.py b/homeassistant/components/datadog/config_flow.py new file mode 100644 index 00000000000..a2ad74e2c57 --- /dev/null +++ b/homeassistant/components/datadog/config_flow.py @@ -0,0 +1,203 @@ +"""Config flow for Datadog.""" + +from typing import Any + +from datadog import DogStatsd +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + + +class DatadogConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Datadog.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user config flow.""" + errors: dict[str, str] = {} + if user_input: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + # Validate connection to Datadog Agent + success = await validate_datadog_connection( + self.hass, + user_input, + ) + if not success: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=f"Datadog {user_input['host']}", + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + options={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + }, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_PREFIX, default=DEFAULT_PREFIX): str, + vol.Required(CONF_RATE, default=DEFAULT_RATE): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + # Check for duplicates + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + + result = await self.async_step_user(user_input) + + if errors := result.get("errors"): + await deprecate_yaml_issue(self.hass, False) + return self.async_abort(reason=errors["base"]) + + await deprecate_yaml_issue(self.hass, True) + return result + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow handler.""" + return DatadogOptionsFlowHandler() + + +class DatadogOptionsFlowHandler(OptionsFlow): + """Handle Datadog options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the Datadog options.""" + errors: dict[str, str] = {} + data = self.config_entry.data + options = self.config_entry.options + + if user_input is None: + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_PREFIX, + default=options.get( + CONF_PREFIX, data.get(CONF_PREFIX, DEFAULT_PREFIX) + ), + ): str, + vol.Required( + CONF_RATE, + default=options.get( + CONF_RATE, data.get(CONF_RATE, DEFAULT_RATE) + ), + ): int, + } + ), + errors={}, + ) + + success = await validate_datadog_connection( + self.hass, + {**data, **user_input}, + ) + if success: + return self.async_create_entry( + data={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + } + ) + + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_PREFIX, default=options[CONF_PREFIX]): str, + vol.Required(CONF_RATE, default=options[CONF_RATE]): int, + } + ), + errors=errors, + ) + + +async def validate_datadog_connection( + hass: HomeAssistant, user_input: dict[str, Any] +) -> bool: + """Attempt to send a test metric to the Datadog agent.""" + try: + client = DogStatsd(user_input[CONF_HOST], user_input[CONF_PORT]) + await hass.async_add_executor_job(client.increment, "connection_test") + except (OSError, ValueError): + return False + else: + return True + + +async def deprecate_yaml_issue( + hass: HomeAssistant, + import_success: bool, +) -> None: + """Create an issue to deprecate YAML config.""" + if import_success: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.2.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_connection_error", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_connection_error", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + "url": f"/config/integrations/dashboard/add?domain={DOMAIN}", + }, + ) diff --git a/homeassistant/components/datadog/const.py b/homeassistant/components/datadog/const.py new file mode 100644 index 00000000000..7c9a0311228 --- /dev/null +++ b/homeassistant/components/datadog/const.py @@ -0,0 +1,10 @@ +"""Constants for the Datadog integration.""" + +DOMAIN = "datadog" + +CONF_RATE = "rate" + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8125 +DEFAULT_PREFIX = "hass" +DEFAULT_RATE = 1 diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index ca9681effca..798a314e307 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -2,9 +2,10 @@ "domain": "datadog", "name": "Datadog", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/datadog", "iot_class": "local_push", "loggers": ["datadog"], "quality_scale": "legacy", - "requirements": ["datadog==0.15.0"] + "requirements": ["datadog==0.52.0"] } diff --git a/homeassistant/components/datadog/strings.json b/homeassistant/components/datadog/strings.json new file mode 100644 index 00000000000..86bb2019fc1 --- /dev/null +++ b/homeassistant/components/datadog/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Datadog Agent's address and port.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "prefix": "Prefix", + "rate": "Rate" + }, + "data_description": { + "host": "The hostname or IP address of the Datadog Agent.", + "port": "Port the Datadog Agent is listening on", + "prefix": "Metric prefix to use", + "rate": "The sample rate of UDP packets sent to Datadog." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "options": { + "step": { + "init": { + "description": "Update the Datadog configuration.", + "data": { + "prefix": "[%key:component::datadog::config::step::user::data::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data::rate%]" + }, + "data_description": { + "prefix": "[%key:component::datadog::config::step::user::data_description::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data_description::rate%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml_import_connection_error": { + "title": "{domain} YAML configuration import failed", + "description": "There was an error connecting to the Datadog Agent when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the {domain} configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index ad7ddcba285..0c001921c7a 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any +from homeassistant.components import media_source from homeassistant.components.media_player import ( BrowseMedia, MediaClass, @@ -396,6 +397,15 @@ class DemoBrowsePlayer(AbstractDemoPlayer): _attr_supported_features = BROWSE_PLAYER_SUPPORT + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the websocket media browsing helper.""" + + return await media_source.async_browse_media(self.hass, media_content_id) + class DemoGroupPlayer(AbstractDemoPlayer): """A Demo media player that supports grouping.""" diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 38019cff3c1..ba00bcaedb9 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -19,10 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF SUPPORT_BASIC_SERVICES = ( - VacuumEntityFeature.STATE - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.BATTERY + VacuumEntityFeature.STATE | VacuumEntityFeature.START | VacuumEntityFeature.STOP ) SUPPORT_MOST_SERVICES = ( @@ -31,7 +28,6 @@ SUPPORT_MOST_SERVICES = ( | VacuumEntityFeature.STOP | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED ) @@ -46,18 +42,17 @@ SUPPORT_ALL_SERVICES = ( | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATUS - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.LOCATE | VacuumEntityFeature.MAP | VacuumEntityFeature.CLEAN_SPOT ) FAN_SPEEDS = ["min", "medium", "high", "max"] -DEMO_VACUUM_COMPLETE = "0_Ground_floor" -DEMO_VACUUM_MOST = "1_First_floor" -DEMO_VACUUM_BASIC = "2_Second_floor" -DEMO_VACUUM_MINIMAL = "3_Third_floor" -DEMO_VACUUM_NONE = "4_Fourth_floor" +DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor" +DEMO_VACUUM_MOST = "Demo vacuum 1 first floor" +DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor" +DEMO_VACUUM_MINIMAL = "Demo vacuum 3 third floor" +DEMO_VACUUM_NONE = "Demo vacuum 4 fourth floor" async def async_setup_entry( @@ -90,12 +85,6 @@ class StateDemoVacuum(StateVacuumEntity): self._attr_activity = VacuumActivity.DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 - self._battery_level = 100 - - @property - def battery_level(self) -> int: - """Return the current battery level of the vacuum.""" - return max(0, min(100, self._battery_level)) @property def fan_speed(self) -> str: @@ -117,7 +106,6 @@ class StateDemoVacuum(StateVacuumEntity): if self._attr_activity != VacuumActivity.CLEANING: self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 - self._battery_level -= 1 self.schedule_update_ha_state() def pause(self) -> None: @@ -142,7 +130,6 @@ class StateDemoVacuum(StateVacuumEntity): """Perform a spot clean-up.""" self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 - self._battery_level -= 1 self.schedule_update_ha_state() def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index da2b601317a..8cead5f4992 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -53,8 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> raise ConfigEntryNotReady from ex receiver = connect_denonavr.receiver - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = receiver await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -100,10 +98,3 @@ async def async_unload_entry( _LOGGER.debug("Removing zone3 from DenonAvr") return unload_ok - - -async def update_listener( - hass: HomeAssistant, config_entry: DenonavrConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 930d0e009ac..204471a13b4 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,7 +10,11 @@ import denonavr from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client @@ -51,7 +55,7 @@ DEFAULT_USE_TELNET_NEW_INSTALL = True CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index c5a1b9aeb63..8fea21b707e 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==1.1.1"], + "requirements": ["denonavr==1.1.2"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 0806a8f824d..3d4c62ee1c7 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import HomeAssistant @@ -9,12 +11,18 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE] ) @@ -25,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE], - source_entity_removed=source_entity_removed, ) ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) @@ -54,3 +58,63 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,)) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + new_options = {**config_entry.options} + + if new_options.get("unit_prefix") == "none": + # Before we had support for optional selectors, "none" was used for selecting nothing + del new_options["unit_prefix"] + + hass.config_entries.async_update_entry( + config_entry, options=new_options, version=1, minor_version=2 + ) + + if config_entry.minor_version < 3: + # Remove the derivative config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_SOURCE] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=3 + ) + + if config_entry.minor_version < 4: + # Ensure we use the correct units + new_options = {**config_entry.options} + + if new_options.get("unit_prefix") == "\u00b5": + # Ensure we use the preferred coding of μ + new_options["unit_prefix"] = "\u03bc" + + hass.config_entries.async_update_entry( + config_entry, options=new_options, version=1, minor_version=4 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 37d54e04f7f..be371837442 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -36,7 +36,7 @@ from .const import ( UNIT_PREFIXES = [ selector.SelectOptionDict(value="n", label="n (nano)"), - selector.SelectOptionDict(value="µ", label="µ (micro)"), + selector.SelectOptionDict(value="μ", label="μ (micro)"), selector.SelectOptionDict(value="m", label="m (milli)"), selector.SelectOptionDict(value="k", label="k (kilo)"), selector.SelectOptionDict(value="M", label="M (mega)"), @@ -94,6 +94,7 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict: max=6, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="decimals", + translation_key="round", ), ), vol.Required(CONF_TIME_WINDOW): selector.DurationSelector(), @@ -140,6 +141,9 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 4 + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 60f4611c5eb..68ee5739ab7 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -34,8 +34,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -64,7 +63,7 @@ ATTR_SOURCE_ID = "source" UNIT_PREFIXES = { None: 1, "n": 1e-9, - "µ": 1e-6, + "μ": 1e-6, "m": 1e-3, "k": 1e3, "M": 1e6, @@ -118,30 +117,21 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - - if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": - # Before we had support for optional selectors, "none" was used for selecting nothing - unit_prefix = None - if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): max_sub_interval = cv.time_period(max_sub_interval_dict) else: max_sub_interval = None derivative_sensor = DerivativeSensor( + hass, name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), source_entity=source_entity_id, time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]), unique_id=config_entry.entry_id, unit_of_measurement=None, - unit_prefix=unit_prefix, + unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -156,6 +146,7 @@ async def async_setup_platform( ) -> None: """Set up the derivative sensor.""" derivative = DerivativeSensor( + hass, name=config.get(CONF_NAME), round_digits=config[CONF_ROUND_DIGITS], source_entity=config[CONF_SOURCE], @@ -178,6 +169,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, *, name: str | None, round_digits: int, @@ -188,16 +180,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity): unit_time: UnitOfTime, max_sub_interval: timedelta | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the derivative sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._sensor_source_id = source_entity self._round_digits = round_digits self._attr_native_value = round(Decimal(0), round_digits) # List of tuples with (timestamp_start, timestamp_end, derivative) self._state_list: list[tuple[datetime, datetime, Decimal]] = [] + self._last_valid_state_time: tuple[str, datetime] | None = None self._attr_name = name if name is not None else f"{source_entity} derivative" self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} @@ -242,6 +237,25 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if (current_time - time_end).total_seconds() < self._time_window ] + def _handle_invalid_source_state(self, state: State | None) -> bool: + # Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false. + if not state or state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return False + if not _is_decimal_state(state.state): + self._attr_available = True + self._write_native_value(None) + return False + self._attr_available = True + return True + + def _write_native_value(self, derivative: Decimal | None) -> None: + self._attr_native_value = ( + None if derivative is None else round(derivative, self._round_digits) + ) + self.async_write_ha_state() + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() @@ -255,8 +269,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity): Decimal(restored_data.native_value), # type: ignore[arg-type] self._round_digits, ) - except SyntaxError as err: - _LOGGER.warning("Could not restore last state: %s", err) + except (InvalidOperation, TypeError): + self._attr_native_value = None def schedule_max_sub_interval_exceeded(source_state: State | None) -> None: """Schedule calculation using the source state and max_sub_interval. @@ -280,9 +294,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._prune_state_list(now) derivative = self._calc_derivative_from_state_list(now) - self._attr_native_value = round(derivative, self._round_digits) - - self.async_write_ha_state() + self._write_native_value(derivative) # If derivative is now zero, don't schedule another timeout callback, as it will have no effect if derivative != 0: @@ -299,36 +311,59 @@ class DerivativeSensor(RestoreSensor, SensorEntity): """Handle constant sensor state.""" self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] + if not self._handle_invalid_source_state(new_state): + return + + assert new_state if self._attr_native_value == Decimal(0): # If the derivative is zero, and the source sensor hasn't # changed state, then we know it will still be zero. return schedule_max_sub_interval_exceeded(new_state) - new_state = event.data["new_state"] - if new_state is not None: - calc_derivative( - new_state, new_state.state, event.data["old_last_reported"] - ) + calc_derivative( + new_state, + new_state.state, + event.data["last_reported"], + event.data["old_last_reported"], + ) @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: """Handle changed sensor state.""" self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] + if not self._handle_invalid_source_state(new_state): + return + + assert new_state schedule_max_sub_interval_exceeded(new_state) old_state = event.data["old_state"] - if new_state is not None and old_state is not None: - calc_derivative(new_state, old_state.state, old_state.last_reported) + if old_state is not None: + calc_derivative( + new_state, + old_state.state, + new_state.last_updated, + old_state.last_reported, + ) + else: + # On first state change from none, update availability + self.async_write_ha_state() def calc_derivative( - new_state: State, old_value: str, old_last_reported: datetime + new_state: State, + old_value: str, + new_timestamp: datetime, + old_timestamp: datetime, ) -> None: """Handle the sensor state changes.""" - if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - return + if not _is_decimal_state(old_value): + if self._last_valid_state_time: + old_value = self._last_valid_state_time[0] + old_timestamp = self._last_valid_state_time[1] + else: + # Sensor becomes valid for the first time, just keep the restored value + self.async_write_ha_state() + return if self.native_unit_of_measurement is None: unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -336,18 +371,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): "" if unit is None else unit ) - # filter out all derivatives older than `time_window` from our window list - self._state_list = [ - (time_start, time_end, state) - for time_start, time_end, state in self._state_list - if (new_state.last_reported - time_end).total_seconds() - < self._time_window - ] + self._prune_state_list(new_timestamp) try: - elapsed_time = ( - new_state.last_reported - old_last_reported - ).total_seconds() + elapsed_time = (new_timestamp - old_timestamp).total_seconds() delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value @@ -376,34 +403,28 @@ class DerivativeSensor(RestoreSensor, SensorEntity): return # add latest derivative to the window list - self._state_list.append( - (old_last_reported, new_state.last_reported, new_derivative) + self._state_list.append((old_timestamp, new_timestamp, new_derivative)) + self._last_valid_state_time = ( + new_state.state, + new_timestamp, ) - def calculate_weight( - start: datetime, end: datetime, now: datetime - ) -> float: - window_start = now - timedelta(seconds=self._time_window) - if start < window_start: - weight = (end - window_start).total_seconds() / self._time_window - else: - weight = (end - start).total_seconds() / self._time_window - return weight - # If outside of time window just report derivative (is the same as modeling it in the window), # otherwise take the weighted average with the previous derivatives if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = Decimal("0.00") - for start, end, value in self._state_list: - weight = calculate_weight(start, end, new_state.last_reported) - derivative = derivative + (value * Decimal(weight)) - self._attr_native_value = round(derivative, self._round_digits) - self.async_write_ha_state() + derivative = self._calc_derivative_from_state_list(new_timestamp) + self._write_native_value(derivative) + + source_state = self.hass.states.get(self._sensor_source_id) + if source_state is None or source_state.state in [ + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ]: + self._attr_available = False if self._max_sub_interval is not None: - source_state = self.hass.states.get(self._sensor_source_id) schedule_max_sub_interval_exceeded(source_state) @callback diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index 5081e7f3b35..551d0912a94 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -52,6 +52,11 @@ "h": "Hours", "d": "Days" } + }, + "round": { + "unit_of_measurement": { + "decimals": "decimals" + } } } } diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 92901f8e857..63be9641aeb 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -10,6 +10,7 @@ from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( + Condition, ConditionCheckerType, trace_condition_function, ) @@ -51,20 +52,38 @@ class DeviceAutomationConditionProtocol(Protocol): """List conditions.""" -async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate device condition config.""" - return await async_validate_device_automation_config( - hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION - ) +class DeviceCondition(Condition): + """Device condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + self._hass = hass + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate device condition config.""" + return await async_validate_device_automation_config( + hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION + ) + + async def async_get_checker(self) -> condition.ConditionCheckerType: + """Test a device condition.""" + platform = await async_get_device_automation_platform( + self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION + ) + return trace_condition_function( + platform.async_condition_from_config(self._hass, self._config) + ) -async def async_condition_from_config( - hass: HomeAssistant, config: ConfigType -) -> condition.ConditionCheckerType: - """Test a device condition.""" - platform = await async_get_device_automation_platform( - hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION - ) - return trace_condition_function(platform.async_condition_from_config(hass, config)) +CONDITIONS: dict[str, type[Condition]] = { + "_device": DeviceCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 3fdfa60870a..95db596c3ef 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index c4f57b2398a..64220949270 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -7,45 +7,39 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlow, - ConfigFlowResult, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import configure_mydevolo from .const import DOMAIN, SUPPORTED_MODEL_TYPES from .exceptions import CredentialsInvalid, UuidChanged +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a devolo HomeControl config flow.""" VERSION = 1 - _reauth_entry: ConfigEntry - - def __init__(self) -> None: - """Initialize devolo Home Control flow.""" - self.data_schema = { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - if user_input is None: - return self._show_form(step_id="user") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form(step_id="user", errors={"base": "invalid_auth"}) + errors: dict[str, str] = {} + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -61,42 +55,47 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by zeroconf.""" - if user_input is None: - return self._show_form(step_id="zeroconf_confirm") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form( - step_id="zeroconf_confirm", errors={"base": "invalid_auth"} - ) + errors: dict[str, str] = {} + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="zeroconf_confirm", data_schema=DATA_SCHEMA, errors=errors + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauthentication.""" - self._reauth_entry = self._get_reauth_entry() - self.data_schema = { - vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, - vol.Required(CONF_PASSWORD): str, - } return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by reauthentication.""" - if user_input is None: - return self._show_form(step_id="reauth_confirm") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form( - step_id="reauth_confirm", errors={"base": "invalid_auth"} - ) - except UuidChanged: - return self._show_form( - step_id="reauth_confirm", errors={"base": "reauth_failed"} - ) + errors: dict[str, str] = {} + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=self.init_data[CONF_USERNAME]): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + except UuidChanged: + errors["base"] = "reauth_failed" + + return self.async_show_form( + step_id="reauth_confirm", data_schema=data_schema, errors=errors + ) async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Connect to mydevolo.""" @@ -119,21 +118,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - if self._reauth_entry.unique_id != uuid: + if self.unique_id != uuid: # The old user and the new user are not the same. This could mess-up everything as all unique IDs might change. raise UuidChanged + reauth_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( - self._reauth_entry, data=user_input, unique_id=uuid - ) - - @callback - def _show_form( - self, step_id: str, errors: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Show the form to the user.""" - return self.async_show_form( - step_id=step_id, - data_schema=vol.Schema(self.data_schema), - errors=errors if errors else {}, + reauth_entry, data=user_input, unique_id=uuid ) diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index f23244f1b50..bafef2b02c9 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py deleted file mode 100644 index 3e2d551d1f8..00000000000 --- a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Base class for multi level switches in devolo Home Control.""" - -from devolo_home_control_api.devices.zwave import Zwave -from devolo_home_control_api.homecontrol import HomeControl - -from .entity import DevoloDeviceEntity - - -class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): - """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" - - _attr_name = None - - def __init__( - self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str - ) -> None: - """Initialize a multi level switch within devolo Home Control.""" - super().__init__( - homecontrol=homecontrol, - device_instance=device_instance, - element_uid=element_uid, - ) - self._multi_level_switch_property = device_instance.multi_level_switch_property[ - element_uid - ] - - self._value = self._multi_level_switch_property.value diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index 26b450a2cf2..dade8d6a2f9 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -9,7 +9,7 @@ from devolo_home_control_api.devices.zwave import Zwave from devolo_home_control_api.homecontrol import HomeControl from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -35,7 +35,7 @@ class DevoloDeviceEntity(Entity): ) # This is not doing I/O. It fetches an internal state of the API self._attr_should_poll = False self._attr_unique_id = element_uid - self._attr_device_info = DeviceInfo( + self._attr_device_info = dr.DeviceInfo( configuration_url=f"https://{urlparse(device_instance.href).netloc}", identifiers={(DOMAIN, self._device_instance.uid)}, manufacturer=device_instance.brand, @@ -87,6 +87,52 @@ class DevoloDeviceEntity(Entity): self._value = message[1] elif len(message) == 3 and message[2] == "status": # Maybe the API wants to tell us, that the device went on- or offline. - self._attr_available = self._device_instance.is_online() + state = self._device_instance.is_online() + if state != self.available and not state: + _LOGGER.info( + "Device %s is unavailable", + self._device_instance.settings_property[ + "general_device_settings" + ].name, + ) + if state != self.available and state: + _LOGGER.info( + "Device %s is back online", + self._device_instance.settings_property[ + "general_device_settings" + ].name, + ) + self._attr_available = state + elif message[1] == "del" and self.platform.config_entry: + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, self._device_instance.uid)} + ) + if device: + device_registry.async_update_device( + device.id, + remove_config_entry_id=self.platform.config_entry.entry_id, + ) else: _LOGGER.debug("No valid message received: %s", message) + + +class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): + """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" + + _attr_name = None + + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: + """Initialize a multi level switch within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + self._multi_level_switch_property = device_instance.multi_level_switch_property[ + element_uid + ] + + self._value = self._multi_level_switch_property.value diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 8a88081ed05..907a46ec27b 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/devolo_home_control/siren.py b/homeassistant/components/devolo_home_control/siren.py index 5e4df944b3c..e3f91ca4d7d 100644 --- a/homeassistant/components/devolo_home_control/siren.py +++ b/homeassistant/components/devolo_home_control/siren.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index a5a8086ba47..057faa446e6 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -19,6 +19,16 @@ "password": "Password of your mydevolo account." } }, + "reauth_confirm": { + "data": { + "username": "[%key:component::devolo_home_control::config::step::user::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]", + "password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]" + } + }, "zeroconf_confirm": { "data": { "username": "[%key:component::devolo_home_control::config::step::user::data::username%]", @@ -51,7 +61,7 @@ "message": "Failed to connect to devolo Home Control central unit {gateway_id}." }, "invalid_auth": { - "message": "Authentication failed. Please re-authenticaticate with your mydevolo account." + "message": "Authentication failed. Please re-authenticate with your mydevolo account." }, "maintenance": { "message": "devolo Home Control is currently in maintenance mode." diff --git a/homeassistant/components/devolo_home_network/coordinator.py b/homeassistant/components/devolo_home_network/coordinator.py index d23aa0e935e..5af9afb12ae 100644 --- a/homeassistant/components/devolo_home_network/coordinator.py +++ b/homeassistant/components/devolo_home_network/coordinator.py @@ -207,7 +207,7 @@ class DevoloUptimeGetCoordinator(DevoloDataUpdateCoordinator[int]): class DevoloWifiConnectedStationsGetCoordinator( - DevoloDataUpdateCoordinator[list[ConnectedStationInfo]] + DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] ): """Class to manage fetching data from the WifiGuestAccessGet endpoint.""" @@ -230,10 +230,11 @@ class DevoloWifiConnectedStationsGetCoordinator( ) self.update_method = self.async_get_wifi_connected_station - async def async_get_wifi_connected_station(self) -> list[ConnectedStationInfo]: + async def async_get_wifi_connected_station(self) -> dict[str, ConnectedStationInfo]: """Fetch data from API endpoint.""" assert self.device.device - return await self.device.device.async_get_wifi_connected_station() + clients = await self.device.device.async_get_wifi_connected_station() + return {client.mac_address: client for client in clients} class DevoloWifiGuestAccessGetCoordinator( diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index ad3d3e1cffa..a0cdd381261 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -28,9 +28,9 @@ async def async_setup_entry( ) -> None: """Get all devices and sensors and setup them via config entry.""" device = entry.runtime_data.device - coordinators: dict[str, DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]] = ( - entry.runtime_data.coordinators - ) + coordinators: dict[ + str, DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] + ] = entry.runtime_data.coordinators registry = er.async_get(hass) tracked = set() @@ -38,16 +38,16 @@ async def async_setup_entry( def new_device_callback() -> None: """Add new devices if needed.""" new_entities = [] - for station in coordinators[CONNECTED_WIFI_CLIENTS].data: - if station.mac_address in tracked: + for mac_address in coordinators[CONNECTED_WIFI_CLIENTS].data: + if mac_address in tracked: continue new_entities.append( DevoloScannerEntity( - coordinators[CONNECTED_WIFI_CLIENTS], device, station.mac_address + coordinators[CONNECTED_WIFI_CLIENTS], device, mac_address ) ) - tracked.add(station.mac_address) + tracked.add(mac_address) async_add_entities(new_entities) @callback @@ -82,7 +82,7 @@ async def async_setup_entry( # The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138 class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module - CoordinatorEntity[DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]], + CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]], ScannerEntity, ): """Representation of a devolo device tracker.""" @@ -92,7 +92,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module def __init__( self, - coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]], + coordinator: DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]], device: Device, mac: str, ) -> None: @@ -109,14 +109,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module if not self.coordinator.data: return {} - station = next( - ( - station - for station in self.coordinator.data - if station.mac_address == self.mac_address - ), - None, - ) + assert self.mac_address + station = self.coordinator.data.get(self.mac_address) if station: attrs["wifi"] = WIFI_APTYPE.get(station.vap_type, STATE_UNKNOWN) attrs["band"] = ( @@ -129,11 +123,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module @property def is_connected(self) -> bool: """Return true if the device is connected to the network.""" - return any( - station - for station in self.coordinator.data - if station.mac_address == self.mac_address - ) + assert self.mac_address + return self.coordinator.data.get(self.mac_address) is not None @property def unique_id(self) -> str: diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index be437314ae4..79b9b846463 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -21,7 +21,7 @@ from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEnt type _DataType = ( LogicalNetwork | DataRate - | list[ConnectedStationInfo] + | dict[str, ConnectedStationInfo] | list[NeighborAPInfo] | WifiGuestAccessGet | bool diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index 31f3a51ebeb..37fb2682883 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,6 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["devolo_plc_api"], + "quality_scale": "silver", "requirements": ["devolo-plc-api==1.5.1"], "zeroconf": [ { diff --git a/homeassistant/components/devolo_home_network/quality_scale.yaml b/homeassistant/components/devolo_home_network/quality_scale.yaml new file mode 100644 index 00000000000..dda228c47e3 --- /dev/null +++ b/homeassistant/components/devolo_home_network/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration 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: 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: | + A change of the IP address is covered by discovery-update-info and a change of the password is covered by reauthentication-flow. No other configuration options are available. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: todo + comment: | + The tracked devices could be own devices with a manual delete option as the API cannot distinguish between stale devices and devices that are not home. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index f4c911bf787..941eec4215d 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -47,7 +47,11 @@ def _last_restart(runtime: int) -> datetime: type _CoordinatorDataType = ( - LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | int + LogicalNetwork + | DataRate + | dict[str, ConnectedStationInfo] + | list[NeighborAPInfo] + | int ) type _SensorDataType = int | float | datetime @@ -79,7 +83,7 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any, Any]] = { ), ), CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[ - list[ConnectedStationInfo], int + dict[str, ConnectedStationInfo], int ]( key=CONNECTED_WIFI_CLIENTS, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 50177a9b13b..c8c2db34e4c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -9,7 +9,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard.", + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network app on the device dashboard.", "password": "Password you protected the device with." } }, @@ -22,8 +22,8 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", - "title": "Discovered devolo home network device", + "description": "Do you want to add the devolo Home Network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo Home Network device", "data": { "password": "[%key:common::config_flow::data::password%]" }, @@ -105,7 +105,7 @@ "message": "Device {title} did not respond" }, "password_protected": { - "message": "Device {title} requires re-authenticatication to set or change the password" + "message": "Device {title} requires re-authentication to set or change the password" }, "password_wrong": { "message": "The used password is wrong" diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index ea2a4f4f820..32abe0684f7 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,8 +15,8 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.2.0", - "aiodiscover==2.7.0", + "aiodhcpwatcher==1.2.1", + "aiodiscover==2.7.1", "cached-ipaddress==0.10.0" ] } diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 7bc43f2c3f5..715285d184e 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, integration_platform, + issue_registry as ir, ) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.json import ( @@ -187,6 +188,7 @@ def async_format_manifest(manifest: Manifest) -> Manifest: async def _async_get_json_file_response( hass: HomeAssistant, data: Mapping[str, Any], + data_issues: list[dict[str, Any]] | None, filename: str, domain: str, d_id: str, @@ -213,6 +215,8 @@ async def _async_get_json_file_response( "setup_times": async_get_domain_setup_times(hass, domain), "data": data, } + if data_issues is not None: + payload["issues"] = data_issues try: json_data = json.dumps(payload, indent=2, cls=ExtendedJSONEncoder) except TypeError: @@ -275,6 +279,14 @@ class DownloadDiagnosticsView(http.HomeAssistantView): filename = f"{config_entry.domain}-{config_entry.entry_id}" + issue_registry = ir.async_get(hass) + issues = issue_registry.issues + data_issues = [ + issue_reg.to_json() + for issue_id, issue_reg in issues.items() + if issue_id[0] == config_entry.domain + ] + if not device_diagnostics: # Config entry diagnostics if info.config_entry_diagnostics is None: @@ -282,7 +294,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): data = await info.config_entry_diagnostics(hass, config_entry) filename = f"{DiagnosticsType.CONFIG_ENTRY}-{filename}" return await _async_get_json_file_response( - hass, data, filename, config_entry.domain, d_id + hass, data, data_issues, filename, config_entry.domain, d_id ) # Device diagnostics @@ -300,5 +312,5 @@ class DownloadDiagnosticsView(http.HomeAssistantView): data = await info.device_diagnostics(hass, config_entry, device) return await _async_get_json_file_response( - hass, data, filename, config_entry.domain, d_id, sub_id + hass, data, data_issues, filename, config_entry.domain, d_id, sub_id ) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 0a8b7422f84..65687debd3a 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import create_async_httpx_client +from .const import DOMAIN from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -30,10 +31,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - # if no exception is raised everything is fine to go meters = await client.meters() except discovergyError.InvalidLogin as err: - raise ConfigEntryAuthFailed("Invalid email or password") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err except Exception as err: raise ConfigEntryNotReady( - "Unexpected error while while getting meters" + translation_domain=DOMAIN, + translation_key="cannot_connect_meters_setup", ) from err # Init coordinators for meters diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index e3f26ad49f8..2c77ab2388e 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] @@ -51,7 +53,12 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): ) except InvalidLogin as err: raise ConfigEntryAuthFailed( - "Auth expired while fetching last reading" + translation_domain=DOMAIN, + translation_key="invalid_auth", ) from err except (HTTPError, DiscovergyClientError) as err: - raise UpdateFailed(f"Error while fetching last reading: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="reading_update_failed", + translation_placeholders={"meter_id": self.meter.meter_id}, + ) from err diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 2f74928c19e..d3443eaefdf 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pydiscovergy==3.0.2"] } diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 56af1d97304..db49639b937 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -57,13 +57,16 @@ rules: status: exempt comment: | This integration cannot be discovered, it is a connecting to a 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 + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: | + The integration does not have any known limitations. + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -72,12 +75,16 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | The integration does not provide any additional icons. - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: | + No configuration besides credentials. + New credentials will create a new config entry. repair-issues: status: exempt comment: | diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 0058f874a36..911a4a1c4f5 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -23,6 +23,17 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "exceptions": { + "invalid_auth": { + "message": "Authentication failed. Please check your inexogy email and password." + }, + "cannot_connect_meters_setup": { + "message": "Failed to connect and retrieve meters from inexogy during setup. Please ensure the service is reachable and try again." + }, + "reading_update_failed": { + "message": "Error fetching the latest reading for meter {meter_id} from inexogy. The service might be temporarily unavailable or there's a connection issue. Check logs for more details." + } + }, "system_health": { "info": { "api_endpoint_reachable": "inexogy API endpoint reachable" diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json index 8afc44a082e..00867e98511 100644 --- a/homeassistant/components/dlink/manifest.json +++ b/homeassistant/components/dlink/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyW215"], - "requirements": ["pyW215==0.7.0"] + "requirements": ["pyW215==0.8.0"] } diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 119d1d31d52..eac8ddcf713 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 0289d5100d6..4a73bf779e0 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.44.0"], + "requirements": ["async-upnp-client==0.45.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 37e0f60849f..3487ce83c7b 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -13,15 +13,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up DNS IP from a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload dnsip config entry.""" diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 6b86f1627bc..0ea2a9d092b 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback @@ -165,13 +165,16 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): ) -class DnsIPOptionsFlowHandler(OptionsFlow): +class DnsIPOptionsFlowHandler(OptionsFlowWithReload): """Handle a option config flow for dnsip integration.""" async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + if self.config_entry.data[CONF_HOSTNAME] == DEFAULT_HOSTNAME: + return self.async_abort(reason="no_options") + errors = {} if user_input is not None: resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER) diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index 39a0fbf7cd3..70472d37917 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -30,7 +30,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "no_options": "The myip hostname requires the default resolvers and therefore cannot be configured." }, "error": { "invalid_resolver": "Invalid IP address or port for resolver" diff --git a/homeassistant/components/dominos/strings.json b/homeassistant/components/dominos/strings.json index 0ceabd7abe8..5d95be478ce 100644 --- a/homeassistant/components/dominos/strings.json +++ b/homeassistant/components/dominos/strings.json @@ -2,11 +2,11 @@ "services": { "order": { "name": "Order", - "description": "Places a set of orders with Dominos Pizza.", + "description": "Places a set of orders with Domino's Pizza.", "fields": { "order_entity_id": { "name": "Order entity", - "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed." + "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all the identified orders will be placed." } } } diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index cb31c7d6314..3522ed00dda 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==11.2.1"] + "requirements": ["pydoods==1.0.2", "Pillow==11.3.0"] } diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 52e68b7521c..96fe9b9bd5f 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.5"] + "requirements": ["py-dormakaba-dkey==1.0.6"] } diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index eb844ad8d3f..8b33c1d7ed3 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -18,6 +18,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If path is relative, we assume relative to Home Assistant config dir if not os.path.isabs(download_path): download_path = hass.config.path(download_path) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DOWNLOAD_DIR: download_path} + ) if not await hass.async_add_executor_job(os.path.isdir, download_path): _LOGGER.error( diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index cce8c9d65b0..0ccaee232d7 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -11,6 +11,7 @@ import requests import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -34,24 +35,33 @@ def download_file(service: ServiceCall) -> None: entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0] download_path = entry.data[CONF_DOWNLOAD_DIR] + url: str = service.data[ATTR_URL] + subdir: str | None = service.data.get(ATTR_SUBDIR) + target_filename: str | None = service.data.get(ATTR_FILENAME) + overwrite: bool = service.data[ATTR_OVERWRITE] + + if subdir: + # Check the path + try: + raise_if_invalid_path(subdir) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_invalid", + translation_placeholders={"subdir": subdir}, + ) from err + if os.path.isabs(subdir): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_not_relative", + translation_placeholders={"subdir": subdir}, + ) def do_download() -> None: """Download the file.""" + final_path = None + filename = target_filename try: - url = service.data[ATTR_URL] - - subdir = service.data.get(ATTR_SUBDIR) - - filename = service.data.get(ATTR_FILENAME) - - overwrite = service.data.get(ATTR_OVERWRITE) - - if subdir: - # Check the path - raise_if_invalid_path(subdir) - - final_path = None - req = requests.get(url, stream=True, timeout=10) if req.status_code != HTTPStatus.OK: @@ -65,12 +75,10 @@ def download_file(service: ServiceCall) -> None: else: if filename is None and "content-disposition" in req.headers: - match = re.findall( + if match := re.search( r"filename=(\S+)", req.headers["content-disposition"] - ) - - if match: - filename = match[0].strip("'\" ") + ): + filename = match.group(1).strip("'\" ") if not filename: filename = os.path.basename(url).strip() diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index 7db7ea459d7..98c4a0a6c82 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -12,6 +12,14 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "exceptions": { + "subdir_invalid": { + "message": "Invalid subdirectory, got: {subdir}" + }, + "subdir_not_relative": { + "message": "Subdirectory must be relative, got: {subdir}" + } + }, "services": { "download_file": { "name": "Download file", diff --git a/homeassistant/components/dremel_3d_printer/entity.py b/homeassistant/components/dremel_3d_printer/entity.py index 46686e47e1f..c2823e594a8 100644 --- a/homeassistant/components/dremel_3d_printer/entity.py +++ b/homeassistant/components/dremel_3d_printer/entity.py @@ -30,6 +30,7 @@ class Dremel3DPrinterEntity(CoordinatorEntity[Dremel3DPrinterDataUpdateCoordinat """Return device information about this Dremel printer.""" return DeviceInfo( identifiers={(DOMAIN, self._api.get_serial_number())}, + serial_number=self._api.get_serial_number(), manufacturer=self._api.get_manufacturer(), model=self._api.get_model(), name=self._api.get_title(), diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index c69e2e12ea0..cc3356cb8e9 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [ native_unit_of_measurement=UnitOfVolume.GALLONS, suggested_display_precision=1, value_fn=lambda device: device.drop_api.water_used_today(), - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, ), DROPSensorEntityDescription( key=AVERAGE_WATER_USED, diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 918d4e33971..03e89b971fc 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="SHORT_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="LONG_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index e95e9ae870a..7fbfcd573ed 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -222,7 +222,7 @@ "data": { "time_between_update": "Minimum time between entity updates [s]" }, - "title": "DSMR Options" + "title": "DSMR options" } } } diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index d405898a393..6f8bcde12f4 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -263,7 +263,7 @@ "issues": { "cannot_subscribe_mqtt_topic": { "title": "Cannot subscribe to MQTT topic {topic_title}", - "description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running, before starting this integration." + "description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running before starting this integration." } } } diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index 3f421d338a7..4e0ee2d2016 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To identify the desired region, either the warncell ID / name or device tracker is required. The provided device tracker has to contain the attributes 'latitude' and 'longitude'.", + "description": "To identify the desired region, either the warncell ID / name or device tracker is required. The provided device tracker has to contain the attributes 'Latitude' and 'Longitude'.", "data": { "region_identifier": "Warncell ID or name", "region_device_tracker": "Device tracker entity" @@ -14,7 +14,7 @@ "ambiguous_identifier": "The region identifier and device tracker can not be specified together.", "invalid_identifier": "The specified region identifier / device tracker is invalid.", "entity_not_found": "The specified device tracker entity was not found.", - "attribute_not_found": "The required `latitude` or `longitude` attribute was not found in the specified device tracker." + "attribute_not_found": "The required attributes 'Latitude' and 'Longitude' were not found in the specified device tracker." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py deleted file mode 100644 index b43ce3db8c1..00000000000 --- a/homeassistant/components/dweet/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Support for sending data to Dweet.io.""" - -from datetime import timedelta -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - CONF_NAME, - CONF_WHITELIST, - EVENT_STATE_CHANGED, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, state as state_helper -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "dweet" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_WHITELIST, default=[]): vol.All( - cv.ensure_list, [cv.entity_id] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Dweet.io component.""" - conf = config[DOMAIN] - name = conf.get(CONF_NAME) - whitelist = conf.get(CONF_WHITELIST) - json_body = {} - - def dweet_event_listener(event): - """Listen for new messages on the bus and sends them to Dweet.io.""" - state = event.data.get("new_state") - if ( - state is None - or state.state in (STATE_UNKNOWN, "") - or state.entity_id not in whitelist - ): - return - - try: - _state = state_helper.state_as_number(state) - except ValueError: - _state = state.state - - json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state - - send_data(name, json_body) - - hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener) - - return True - - -@Throttle(MIN_TIME_BETWEEN_UPDATES) -def send_data(name, msg): - """Send the collected data to Dweet.io.""" - try: - dweepy.dweet_for(name, msg) - except dweepy.DweepyError: - _LOGGER.error("Error saving data to Dweet.io: %s", msg) diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json deleted file mode 100644 index b4efd0744fb..00000000000 --- a/homeassistant/components/dweet/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "dweet", - "name": "dweet.io", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/dweet", - "iot_class": "cloud_polling", - "loggers": ["dweepy"], - "quality_scale": "legacy", - "requirements": ["dweepy==0.3.0"] -} diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py deleted file mode 100644 index 6110f17f826..00000000000 --- a/homeassistant/components/dweet/sensor.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Support for showing values from Dweet.io.""" - -from __future__ import annotations - -from datetime import timedelta -import json -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.const import ( - CONF_DEVICE, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - CONF_VALUE_TEMPLATE, -) -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 - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Dweet.io Sensor" - -SCAN_INTERVAL = timedelta(minutes=1) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dweet sensor.""" - name = config.get(CONF_NAME) - device = config.get(CONF_DEVICE) - value_template = config.get(CONF_VALUE_TEMPLATE) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - - try: - content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"]) - except dweepy.DweepyError: - _LOGGER.error("Device/thing %s could not be found", device) - return - - if value_template and value_template.render_with_possible_json_value(content) == "": - _LOGGER.error("%s was not found", value_template) - return - - dweet = DweetData(device) - - add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True) - - -class DweetSensor(SensorEntity): - """Representation of a Dweet sensor.""" - - def __init__(self, hass, dweet, name, value_template, unit_of_measurement): - """Initialize the sensor.""" - self.hass = hass - self.dweet = dweet - self._name = name - self._value_template = value_template - self._state = None - self._unit_of_measurement = unit_of_measurement - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @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.""" - return self._state - - def update(self) -> None: - """Get the latest data from REST API.""" - self.dweet.update() - - if self.dweet.data is None: - self._state = None - else: - values = json.dumps(self.dweet.data[0]["content"]) - self._state = self._value_template.render_with_possible_json_value( - values, None - ) - - -class DweetData: - """The class for handling the data retrieval.""" - - def __init__(self, device): - """Initialize the sensor.""" - self._device = device - self.data = None - - def update(self): - """Get the latest data from Dweet.io.""" - try: - self.data = dweepy.get_latest_dweet_for(self._device) - except dweepy.DweepyError: - _LOGGER.warning("Device %s doesn't contain any data", self._device) - self.data = None diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 3411882b725..1eb6b4f2e44 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -12,7 +12,7 @@ from .bridge import DynaliteBridge from .const import DOMAIN, LOGGER, PLATFORMS from .convert_config import convert_config from .panel import async_register_dynalite_frontend -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -21,7 +21,7 @@ type DynaliteConfigEntry = ConfigEntry[DynaliteBridge] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - setup_services(hass) + async_setup_services(hass) await async_register_dynalite_frontend(hass) diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py index d0d57a582b4..2621df61853 100644 --- a/homeassistant/components/dynalite/services.py +++ b/homeassistant/components/dynalite/services.py @@ -50,7 +50,7 @@ async def _request_channel_level(service_call: ServiceCall) -> None: @callback -def setup_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Set up the Dynalite platform.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 115c91eceeb..cbb3a230c90 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,7 +20,6 @@ from homeassistant.const import Platform _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_AVAILABLE_SENSORS = "available_sensors" ATTR_ACTIVE_SENSORS = "active_sensors" diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 32bf5d3ba15..5997559c3cf 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -4,10 +4,12 @@ from collections.abc import Callable from dataclasses import dataclass from deebot_client.capabilities import CapabilityEvent -from deebot_client.events.base import Event +from deebot_client.events import Event from deebot_client.events.water_info import MopAttachedEvent +from sucks import VacBot from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -16,7 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsLegacyEntity, +) from .util import get_supported_entities @@ -47,12 +53,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" + controller = config_entry.runtime_data + async_add_entities( get_supported_entities( config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS ) ) + legacy_entities = [] + for device in controller.legacy_devices: + if not controller.legacy_entity_is_added(device, "battery_charging"): + controller.add_legacy_entity(device, "battery_charging") + legacy_entities.append(EcovacsLegacyBatteryChargingSensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) + class EcovacsBinarySensor[EventT: Event]( EcovacsDescriptionEntity[CapabilityEvent[EventT]], @@ -71,3 +88,33 @@ class EcovacsBinarySensor[EventT: Event]( self.async_write_ha_state() self._subscribe(self._capability.event, on_event) + + +class EcovacsLegacyBatteryChargingSensor(EcovacsLegacyEntity, BinarySensorEntity): + """Legacy battery charging sensor.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_charging" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.statusEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if self.device.charge_status is None: + return None + return bool(self.device.is_charging) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 97739f698d9..ddd464bdc6a 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"] } diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index e84485228e4..b368b92a579 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -37,6 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import StateType from . import EcovacsConfigEntry @@ -225,7 +226,7 @@ async def async_setup_entry( async_add_entities(entities) - async def _add_legacy_entities() -> None: + async def _add_legacy_lifespan_entities() -> None: entities = [] for device in controller.legacy_devices: for description in LEGACY_LIFESPAN_SENSORS: @@ -242,14 +243,21 @@ async def async_setup_entry( async_add_entities(entities) def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None: - hass.create_task(_add_legacy_entities()) + hass.create_task(_add_legacy_lifespan_entities()) + legacy_entities = [] for device in controller.legacy_devices: config_entry.async_on_unload( device.lifespanEvents.subscribe( _fire_ecovacs_legacy_lifespan_event ).unsubscribe ) + if not controller.legacy_entity_is_added(device, "battery_status"): + controller.add_legacy_entity(device, "battery_status") + legacy_entities.append(EcovacsLegacyBatterySensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) class EcovacsSensor( @@ -344,6 +352,44 @@ class EcovacsErrorSensor( self._subscribe(self._capability.event, on_event) +class EcovacsLegacyBatterySensor(EcovacsLegacyEntity, SensorEntity): + """Legacy battery sensor.""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = SensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_status" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.batteryEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if (status := self.device.battery_status) is not None: + return status * 100 # type: ignore[no-any-return] + return None + + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + return icon_for_battery_level( + battery_level=self.native_value, charging=self.device.is_charging + ) + + class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity): """Legacy Lifespan sensor.""" diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 6570b80e920..86a30558375 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any from deebot_client.capabilities import Capabilities, DeviceType from deebot_client.device import Device -from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent +from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent from deebot_client.models import CleanAction, CleanMode, Room, State import sucks @@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify from . import EcovacsConfigEntry @@ -71,8 +70,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -89,11 +87,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): lambda _: self.schedule_update_ha_state() ) ) - self._event_listeners.append( - self.device.batteryEvents.subscribe( - lambda _: self.schedule_update_ha_state() - ) - ) self._event_listeners.append( self.device.lifespanEvents.subscribe( lambda _: self.schedule_update_ha_state() @@ -137,21 +130,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): return None - @property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - if self.device.battery_status is not None: - return self.device.battery_status * 100 # type: ignore[no-any-return] - - return None - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return icon_for_battery_level( - battery_level=self.battery_level, charging=self.device.is_charging - ) - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" @@ -238,7 +216,6 @@ class EcovacsVacuum( VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATE @@ -265,10 +242,6 @@ class EcovacsVacuum( """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_battery(event: BatteryEvent) -> None: - self._attr_battery_level = event.value - self.async_write_ha_state() - async def on_rooms(event: RoomsEvent) -> None: self._rooms = event.rooms self.async_write_ha_state() @@ -277,7 +250,6 @@ class EcovacsVacuum( self._attr_activity = _STATE_TO_VACUUM_STATE[event.state] self.async_write_ha_state() - self._subscribe(self._capability.battery.event, on_battery) self._subscribe(self._capability.state.event, on_status) if self._capability.fan_speed: diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 7d37aa40b86..ccaaeaae3de 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -106,6 +106,7 @@ ECOWITT_SENSORS_MAPPING: Final = { native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, ), EcoWittSensorTypes.CO2_PPM: SensorEntityDescription( key="CO2_PPM", @@ -191,12 +192,14 @@ ECOWITT_SENSORS_MAPPING: Final = { device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), EcoWittSensorTypes.SPEED_MPH: SensorEntityDescription( key="SPEED_MPH", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), EcoWittSensorTypes.PRESSURE_HPA: SensorEntityDescription( key="PRESSURE_HPA", diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index b0432267c8e..09fbaa601b3 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -10,7 +10,12 @@ from eheimdigital.device import EheimDigitalDevice from eheimdigital.hub import EheimDigitalHub import voluptuous as vol -from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -126,3 +131,52 @@ class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=CONFIG_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the config entry.""" + if user_input is None: + return self.async_show_form( + step_id=SOURCE_RECONFIGURE, data_schema=CONFIG_SCHEMA + ) + + self._async_abort_entries_match(user_input) + errors: dict[str, str] = {} + hub = EheimDigitalHub( + host=user_input[CONF_HOST], + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await self.async_set_unique_id(hub.main.mac_address) + await hub.close() + except (ClientError, TimeoutError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + LOGGER.exception("Unknown exception occurred") + else: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + return self.async_show_form( + step_id=SOURCE_RECONFIGURE, + data_schema=CONFIG_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 99f2a0a9c56..d414b559aa1 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -7,8 +7,8 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["eheimdigital"], - "quality_scale": "bronze", - "requirements": ["eheimdigital==1.2.0"], + "quality_scale": "platinum", + "requirements": ["eheimdigital==1.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml index c1490b352c2..96fa798f9cf 100644 --- a/homeassistant/components/eheimdigital/quality_scale.yaml +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -46,22 +46,24 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: 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: done exception-translations: done - icon-translations: todo - reconfiguration-flow: todo - repair-issues: todo + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: No repairs. stale-devices: done # Platinum diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 77cffb4a709..c629ff622cb 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -4,6 +4,14 @@ "discovery_confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::eheimdigital::config::step::user::data_description::host%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -15,7 +23,9 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The identifier does not match the previous identifier" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index e5807fec67c..a930dea43ed 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -25,7 +25,8 @@ PLATFORMS: list[Platform] = [Platform.TTS] async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None: """Get ElevenLabs model from their API by the model_id.""" - models = await client.models.get_all() + models = await client.models.list() + for maybe_model in models: if maybe_model.model_id == model_id: return maybe_model diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 227749bf82c..fc248235834 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -23,14 +23,12 @@ from . import ElevenLabsConfigEntry from .const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -51,7 +49,8 @@ async def get_voices_models( httpx_client = get_async_client(hass) client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client) voices = (await client.voices.get_all()).voices - models = await client.models.get_all() + models = await client.models.list() + voices_dict = { voice.voice_id: voice.name for voice in sorted(voices, key=lambda v: v.name or "") @@ -78,8 +77,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY]) - except ApiError: - errors["base"] = "invalid_api_key" + except ApiError as exc: + errors["base"] = "unknown" + details = getattr(exc, "body", {}).get("detail", {}) + if details: + status = details.get("status") + if status == "invalid_api_key": + errors["base"] = "invalid_api_key" else: return self.async_create_entry( title="ElevenLabs", @@ -206,12 +210,6 @@ class ElevenLabsOptionsFlow(OptionsFlow): vol.Coerce(float), vol.Range(min=0, max=1), ), - vol.Optional( - CONF_OPTIMIZE_LATENCY, - default=self.config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), - ): vol.All(int, vol.Range(min=0, max=4)), vol.Optional( CONF_STYLE, default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE), diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 1de92f95e43..2629e62d2fc 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -7,7 +7,6 @@ CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" CONF_STABILITY = "stability" CONF_SIMILARITY = "similarity" -CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency" CONF_STYLE = "style" CONF_USE_SPEAKER_BOOST = "use_speaker_boost" DOMAIN = "elevenlabs" @@ -15,6 +14,5 @@ DOMAIN = "elevenlabs" DEFAULT_MODEL = "eleven_multilingual_v2" DEFAULT_STABILITY = 0.5 DEFAULT_SIMILARITY = 0.75 -DEFAULT_OPTIMIZE_LATENCY = 0 DEFAULT_STYLE = 0 DEFAULT_USE_SPEAKER_BOOST = True diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json index eb6df09149a..f36a2383576 100644 --- a/homeassistant/components/elevenlabs/manifest.json +++ b/homeassistant/components/elevenlabs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["elevenlabs"], - "requirements": ["elevenlabs==1.9.0"] + "requirements": ["elevenlabs==2.3.0"] } diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index 8b0205a9e9a..eb497f1a7a6 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -11,7 +11,8 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "options": { @@ -32,14 +33,12 @@ "data": { "stability": "Stability", "similarity": "Similarity", - "optimize_streaming_latency": "Latency", "style": "Style", "use_speaker_boost": "Speaker boost" }, "data_description": { "stability": "Stability of the generated audio. Higher values lead to less emotional audio.", "similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.", - "optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.", "style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.", "use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice." } diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index 61850837075..fc1a950d4b9 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -25,13 +25,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElevenLabsConfigEntry from .const import ( ATTR_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -75,9 +73,6 @@ async def async_setup_entry( config_entry.entry_id, config_entry.title, voice_settings, - config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), ) ] ) @@ -98,7 +93,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): entry_id: str, title: str, voice_settings: VoiceSettings, - latency: int = 0, ) -> None: """Init ElevenLabs TTS service.""" self._client = client @@ -115,7 +109,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): if voice_indices: self._voices.insert(0, self._voices.pop(voice_indices[0])) self._voice_settings = voice_settings - self._latency = latency # Entity attributes self._attr_unique_id = entry_id @@ -144,14 +137,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): voice_id = options.get(ATTR_VOICE, self._default_voice_id) model = options.get(ATTR_MODEL, self._model.model_id) try: - audio = await self._client.generate( + audio = self._client.text_to_speech.convert( text=message, - voice=voice_id, - optimize_streaming_latency=self._latency, + voice_id=voice_id, voice_settings=self._voice_settings, - model=model, + model_id=model, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) + except ApiError as exc: _LOGGER.warning( "Error during processing of TTS request %s", exc, exc_info=True diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 012abcc8c9a..1c081dc86e6 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -69,16 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener(hass: HomeAssistant, entry: EmonCMSConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index 8b3067b2cf4..375077a83d4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -11,12 +11,17 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import selector +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + selector, +) from .const import ( CONF_MESSAGE, @@ -26,6 +31,9 @@ from .const import ( FEED_ID, FEED_NAME, FEED_TAG, + SYNC_MODE, + SYNC_MODE_AUTO, + SYNC_MODE_MANUAL, ) @@ -102,6 +110,17 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): "mode": "dropdown", "multiple": True, } + if user_input.get(SYNC_MODE) == SYNC_MODE_AUTO: + return self.async_create_entry( + title=sensor_name(self.url), + data={ + CONF_URL: self.url, + CONF_API_KEY: self.api_key, + CONF_ONLY_INCLUDE_FEEDID: [ + feed[FEED_ID] for feed in result[CONF_MESSAGE] + ], + }, + ) return await self.async_step_choose_feeds() return self.async_show_form( step_id="user", @@ -110,6 +129,15 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, + vol.Required( + SYNC_MODE, default=SYNC_MODE_MANUAL + ): SelectSelector( + SelectSelectorConfig( + options=[SYNC_MODE_MANUAL, SYNC_MODE_AUTO], + mode=SelectSelectorMode.DROPDOWN, + translation_key=SYNC_MODE, + ) + ), } ), user_input, @@ -151,8 +179,49 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + errors: dict[str, str] = {} + description_placeholders = {} + reconfig_entry = self._get_reconfigure_entry() + if user_input is not None: + url = user_input[CONF_URL] + api_key = user_input[CONF_API_KEY] + emoncms_client = EmoncmsClient( + url, api_key, session=async_get_clientsession(self.hass) + ) + result = await get_feed_list(emoncms_client) + if not result[CONF_SUCCESS]: + errors["base"] = "api_error" + description_placeholders = {"details": result[CONF_MESSAGE]} + else: + await self.async_set_unique_id(await emoncms_client.async_get_uuid()) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfig_entry, + title=sensor_name(url), + data=user_input, + reload_even_if_entry_is_unchanged=False, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_API_KEY): str, + } + ), + user_input or reconfig_entry.data, + ), + errors=errors, + description_placeholders=description_placeholders, + ) -class EmoncmsOptionsFlow(OptionsFlow): + +class EmoncmsOptionsFlow(OptionsFlowWithReload): """Emoncms Options flow handler.""" def __init__(self, config_entry: ConfigEntry) -> None: diff --git a/homeassistant/components/emoncms/const.py b/homeassistant/components/emoncms/const.py index c53f7cc8a9f..329ec9e3a12 100644 --- a/homeassistant/components/emoncms/const.py +++ b/homeassistant/components/emoncms/const.py @@ -2,7 +2,6 @@ import logging -CONF_EXCLUDE_FEEDID = "exclude_feed_id" CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id" CONF_MESSAGE = "message" CONF_SUCCESS = "success" @@ -14,6 +13,9 @@ EMONCMS_UUID_DOC_URL = ( FEED_ID = "id" FEED_NAME = "name" FEED_TAG = "tag" +SYNC_MODE = "sync_mode" +SYNC_MODE_AUTO = "auto" +SYNC_MODE_MANUAL = "manual" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index c7f18cb205e..bc86e6e9bab 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emoncms", "iot_class": "local_polling", - "requirements": ["pyemoncms==0.1.1"] + "requirements": ["pyemoncms==0.1.2"] } diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index c5a25104549..2ca4e28a36d 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -34,13 +34,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .config_flow import sensor_name -from .const import ( - CONF_EXCLUDE_FEEDID, - CONF_ONLY_INCLUDE_FEEDID, - FEED_ID, - FEED_NAME, - FEED_TAG, -) +from .const import CONF_ONLY_INCLUDE_FEEDID, FEED_ID, FEED_NAME, FEED_TAG from .coordinator import EmonCMSConfigEntry, EmoncmsCoordinator SENSORS: dict[str | None, SensorEntityDescription] = { @@ -163,7 +157,7 @@ SENSORS: dict[str | None, SensorEntityDescription] = { native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, ), - "µg/m³": SensorEntityDescription( + "μg/m³": SensorEntityDescription( key="concentration|microgram_per_cubic_meter", translation_key="concentration", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -200,12 +194,11 @@ async def async_setup_entry( ) -> None: """Set up the emoncms sensors.""" name = sensor_name(entry.data[CONF_URL]) - exclude_feeds = entry.data.get(CONF_EXCLUDE_FEEDID) include_only_feeds = entry.options.get( CONF_ONLY_INCLUDE_FEEDID, entry.data.get(CONF_ONLY_INCLUDE_FEEDID) ) - if exclude_feeds is None and include_only_feeds is None: + if include_only_feeds is None: return coordinator = entry.runtime_data diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 451a3fb88e5..e41a7e8bd03 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -7,21 +7,46 @@ "user": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "sync_mode": "Synchronization mode" }, "data_description": { "url": "Server URL starting with the protocol (http or https)", - "api_key": "Your 32 bits API key" + "api_key": "Your 32 bits API key", + "sync_mode": "Pick your feeds manually (default) or synchronize them at once" } }, "choose_feeds": { "data": { "include_only_feed_id": "Choose feeds to include" + }, + "data_description": { + "include_only_feed_id": "Pick the feeds you want to synchronize" + } + }, + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::emoncms::config::step::user::data_description::url%]", + "api_key": "[%key:component::emoncms::config::step::user::data_description::api_key%]" } } }, "abort": { - "already_configured": "This server is already configured" + "already_configured": "This server is already configured", + "unique_id_mismatch": "This emoncms serial number does not match the previous serial number", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "selector": { + "sync_mode": { + "options": { + "auto": "Synchronize all available feeds", + "manual": "Select which feeds to synchronize" + } } }, "entity": { @@ -78,19 +103,14 @@ "init": { "data": { "include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]" + }, + "data_description": { + "include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data_description::include_only_feed_id%]" } } } }, "issues": { - "remove_value_template": { - "title": "The {domain} integration cannot start", - "description": "Configuring {domain} using YAML is being removed and the `{parameter}` parameter cannot be imported.\n\nPlease remove `{parameter}` from your `{domain}` yaml configuration and restart Home Assistant\n\nAlternatively, you may entirely remove the `{domain}` configuration from your configuration.yaml, restart Home Assistant, and add the {domain} integration manually." - }, - "missing_include_only_feed_id": { - "title": "No feed synchronized with the {domain} sensor", - "description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration." - }, "migrate_database": { "title": "Upgrade your emoncms version", "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\nPlease upgrade to at least version 11.5.7 and migrate your emoncms database.\n\nMore info in the [emoncms documentation]({doc_url})" diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 2ab00d6ca42..5394a797272 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -1,10 +1,11 @@ """Support for sending data to Emoncms.""" -from datetime import timedelta -from http import HTTPStatus +from datetime import datetime, timedelta +from functools import partial import logging -import requests +import aiohttp +from pyemoncms import EmoncmsClient import voluptuous as vol from homeassistant.const import ( @@ -17,9 +18,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, state as state_helper -from homeassistant.helpers.event import track_point_in_time +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.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -42,61 +43,51 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_send_to_emoncms( + hass: HomeAssistant, + emoncms_client: EmoncmsClient, + whitelist: list[str], + node: str | int, + _: datetime, +) -> None: + """Send data to Emoncms.""" + payload_dict = {} + + for entity_id in whitelist: + state = hass.states.get(entity_id) + if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): + continue + try: + payload_dict[entity_id] = state_helper.state_as_number(state) + except ValueError: + continue + + if payload_dict: + try: + await emoncms_client.async_input_post(data=payload_dict, node=node) + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.warning("Network error when sending data to Emoncms: %s", err) + except ValueError as err: + _LOGGER.warning("Value error when preparing data for Emoncms: %s", err) + else: + _LOGGER.debug("Sent data to Emoncms: %s", payload_dict) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Emoncms history component.""" conf = config[DOMAIN] whitelist = conf.get(CONF_WHITELIST) + input_node = str(conf.get(CONF_INPUTNODE)) - def send_data(url, apikey, node, payload): - """Send payload data to Emoncms.""" - try: - fullurl = f"{url}/input/post.json" - data = {"apikey": apikey, "data": payload} - parameters = {"node": node} - req = requests.post( - fullurl, params=parameters, data=data, allow_redirects=True, timeout=5 - ) + emoncms_client = EmoncmsClient( + url=conf.get(CONF_URL), + api_key=conf.get(CONF_API_KEY), + session=async_get_clientsession(hass), + ) + async_track_time_interval( + hass, + partial(async_send_to_emoncms, hass, emoncms_client, whitelist, input_node), + timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)), + ) - except requests.exceptions.RequestException: - _LOGGER.error("Error saving data '%s' to '%s'", payload, fullurl) - - else: - if req.status_code != HTTPStatus.OK: - _LOGGER.error( - "Error saving data %s to %s (http status code = %d)", - payload, - fullurl, - req.status_code, - ) - - def update_emoncms(time): - """Send whitelisted entities states regularly to Emoncms.""" - payload_dict = {} - - for entity_id in whitelist: - state = hass.states.get(entity_id) - - if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): - continue - - try: - payload_dict[entity_id] = state_helper.state_as_number(state) - except ValueError: - continue - - if payload_dict: - payload = ",".join(f"{key}:{val}" for key, val in payload_dict.items()) - - send_data( - conf.get(CONF_URL), - conf.get(CONF_API_KEY), - str(conf.get(CONF_INPUTNODE)), - f"{{{payload}}}", - ) - - track_point_in_time( - hass, update_emoncms, time + timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)) - ) - - update_emoncms(dt_util.utcnow()) return True diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json index e73f76f7528..3c8c445b766 100644 --- a/homeassistant/components/emoncms_history/manifest.json +++ b/homeassistant/components/emoncms_history/manifest.json @@ -1,8 +1,9 @@ { "domain": "emoncms_history", "name": "Emoncms History", - "codeowners": [], + "codeowners": ["@alexandrecuer"], "documentation": "https://www.home-assistant.io/integrations/emoncms_history", "iot_class": "local_polling", - "quality_scale": "legacy" + "quality_scale": "legacy", + "requirements": ["pyemoncms==0.1.2"] } diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index be9e2ecb4cc..3e2f6dcbc8f 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -93,6 +93,7 @@ class EmonitorPowerSensor(CoordinatorEntity[EmonitorStatus], SensorEntity): manufacturer="Powerhouse Dynamics, Inc.", name=device_name, sw_version=emonitor_status.hardware.firmware_version, + serial_number=emonitor_status.hardware.serial_number, ) self._attr_extra_state_attributes = {"channel": channel_number} self._attr_native_value = self._paired_attr(self.entity_description.key) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 3dc857d75d9..1105e6f6b86 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -41,13 +41,8 @@ SUPPORTED_STATE_CLASSES = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, } -VALID_ENERGY_UNITS: set[str] = { - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, -} +VALID_ENERGY_UNITS: set[str] = set(UnitOfEnergy) + VALID_ENERGY_UNITS_GAS = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0f46678994f..3590ee9e848 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -21,14 +21,9 @@ from .const import DOMAIN ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ) + sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy) } + ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units ) @@ -39,13 +34,9 @@ GAS_USAGE_DEVICE_CLASSES = ( sensor.SensorDeviceClass.GAS, ) GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ), + sensor.SensorDeviceClass.ENERGY: ENERGY_USAGE_UNITS[ + sensor.SensorDeviceClass.ENERGY + ], sensor.SensorDeviceClass.GAS: ( UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/enigma2/coordinator.py b/homeassistant/components/enigma2/coordinator.py index 9710d7f547f..02e50c2cc06 100644 --- a/homeassistant/components/enigma2/coordinator.py +++ b/homeassistant/components/enigma2/coordinator.py @@ -1,5 +1,6 @@ """Data update coordinator for the Enigma2 integration.""" +import asyncio import logging from openwebif.api import OpenWebIfDevice, OpenWebIfStatus @@ -30,6 +31,8 @@ from .const import CONF_SOURCE_BOUQUET, DOMAIN LOGGER = logging.getLogger(__package__) +SETUP_TIMEOUT = 10 + type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator] @@ -79,7 +82,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): async def _async_setup(self) -> None: """Provide needed data to the device info.""" - about = await self.device.get_about() + about = await asyncio.wait_for(self.device.get_about(), timeout=SETUP_TIMEOUT) self.device.mac_address = about["info"]["ifaces"][0]["mac"] self.device_info["model"] = about["info"]["model"] self.device_info["manufacturer"] = about["info"]["brand"] diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index eee6cb85e6d..62d276b4224 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pyenphase import Envoy -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -43,26 +44,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b }, ) + # register envoy before via_device is used + device_registry = dr.async_get(hass) + if TYPE_CHECKING: + assert envoy.serial_number + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, envoy.serial_number)}, + manufacturer="Enphase", + name=coordinator.name, + model=envoy.envoy_model, + sw_version=str(envoy.firmware), + hw_version=envoy.part_number, + serial_number=envoy.serial_number, + ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when it is updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool: """Unload a config entry.""" coordinator = entry.runtime_data coordinator.async_cancel_token_refresh() coordinator.async_cancel_firmware_refresh() + coordinator.async_cancel_mac_verification() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index dcffef8271b..5dcc2f28c7f 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter -from pyenphase import EnvoyEncharge, EnvoyEnpower +from pyenphase import EnvoyC6CC, EnvoyCollar, EnvoyEncharge, EnvoyEnpower from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -72,6 +72,42 @@ ENPOWER_SENSORS = ( ) +@dataclass(frozen=True, kw_only=True) +class EnvoyCollarBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an Envoy IQ Meter Collar binary sensor entity.""" + + value_fn: Callable[[EnvoyCollar], bool] + + +COLLAR_SENSORS = ( + EnvoyCollarBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("communicating"), + ), +) + + +@dataclass(frozen=True, kw_only=True) +class EnvoyC6CCBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an C6 Combiner controller binary sensor entity.""" + + value_fn: Callable[[EnvoyC6CC], bool] + + +C6CC_SENSORS = ( + EnvoyC6CCBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("communicating"), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, @@ -95,6 +131,18 @@ async def async_setup_entry( for description in ENPOWER_SENSORS ) + if envoy_data.collar: + entities.extend( + EnvoyCollarBinarySensorEntity(coordinator, description) + for description in COLLAR_SENSORS + ) + + if envoy_data.c6cc: + entities.extend( + EnvoyC6CCBinarySensorEntity(coordinator, description) + for description in C6CC_SENSORS + ) + async_add_entities(entities) @@ -126,6 +174,7 @@ class EnvoyEnchargeBinarySensorEntity(EnvoyBaseBinarySensorEntity): name=f"Encharge {serial_number}", sw_version=str(encharge_inventory[self._serial_number].firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=serial_number, ) @property @@ -158,6 +207,7 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): name=f"Enpower {enpower.serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=enpower.serial_number, ) @property @@ -166,3 +216,69 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): enpower = self.data.enpower assert enpower is not None return self.entity_description.value_fn(enpower) + + +class EnvoyCollarBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an IQ Meter Collar binary_sensor entity.""" + + entity_description: EnvoyCollarBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyCollarBinarySensorEntityDescription, + ) -> None: + """Init the Collar base entity.""" + super().__init__(coordinator, description) + collar_data = self.data.collar + assert collar_data is not None + self._attr_unique_id = f"{collar_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, collar_data.serial_number)}, + manufacturer="Enphase", + model="IQ Meter Collar", + name=f"Collar {collar_data.serial_number}", + sw_version=str(collar_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=collar_data.serial_number, + ) + + @property + def is_on(self) -> bool: + """Return the state of the Collar binary_sensor.""" + collar_data = self.data.collar + assert collar_data is not None + return self.entity_description.value_fn(collar_data) + + +class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an C6 Combiner binary_sensor entity.""" + + entity_description: EnvoyC6CCBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyC6CCBinarySensorEntityDescription, + ) -> None: + """Init the C6 Combiner base entity.""" + super().__init__(coordinator, description) + c6cc_data = self.data.c6cc + assert c6cc_data is not None + self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, c6cc_data.serial_number)}, + manufacturer="Enphase", + model="C6 COMBINER CONTROLLER", + name=f"C6 Combiner {c6cc_data.serial_number}", + sw_version=str(c6cc_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=c6cc_data.serial_number, + ) + + @property + def is_on(self) -> bool: + """Return the state of the C6 Combiner binary_sensor.""" + c6cc_data = self.data.c6cc + assert c6cc_data is not None + return self.entity_description.value_fn(c6cc_data) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 5b7bb98527c..9ba11eafa5d 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -335,7 +335,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EnvoyOptionsFlowHandler(OptionsFlow): +class EnvoyOptionsFlowHandler(OptionsFlowWithReload): """Envoy config flow options handler.""" async def async_step_init( diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index cfff0777af5..57ce924733c 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -220,6 +220,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await envoy.setup() assert envoy.serial_number is not None self.envoy_serial_number = envoy.serial_number + _LOGGER.debug("Envoy setup complete for serial: %s", self.envoy_serial_number) if token := self.config_entry.data.get(CONF_TOKEN): with contextlib.suppress(*INVALID_AUTH_ERRORS): # Always set the username and password @@ -227,6 +228,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await envoy.authenticate( username=self.username, password=self.password, token=token ) + _LOGGER.debug("Authorized, validating token lifetime") # The token is valid, but we still want # to refresh it if it's stale right away self._async_refresh_token_if_needed(dt_util.utcnow()) @@ -234,6 +236,8 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # token likely expired or firmware changed # so we fall through to authenticate with # username/password + _LOGGER.debug("setup and auth got INVALID_AUTH_ERRORS") + _LOGGER.debug("Authenticate with username/password only") await self.envoy.authenticate(username=self.username, password=self.password) # Password auth succeeded, so we can update the token # if we are using EnvoyTokenAuth @@ -262,13 +266,16 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): for tries in range(2): try: if not self._setup_complete: + _LOGGER.debug("update on try %s, setup not complete", tries) await self._async_setup_and_authenticate() self._async_mark_setup_complete() # dump all received data in debug mode to assist troubleshooting envoy_data = await envoy.update() except INVALID_AUTH_ERRORS as err: + _LOGGER.debug("update on try %s, INVALID_AUTH_ERRORS %s", tries, err) if self._setup_complete and tries == 0: # token likely expired or firmware changed, try to re-authenticate + _LOGGER.debug("update on try %s, setup was complete, retry", tries) self._setup_complete = False continue raise ConfigEntryAuthFailed( @@ -280,6 +287,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): }, ) from err except EnvoyError as err: + _LOGGER.debug("update on try %s, EnvoyError %s", tries, err) raise UpdateFailed( translation_domain=DOMAIN, translation_key="envoy_error", diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index a1a9d4ed6b4..93244068feb 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -116,6 +116,9 @@ async def async_get_config_entry_diagnostics( entities.append({"entity": entity_dict, "state": state_dict}) device_dict = asdict(device) device_dict.pop("_cache", None) + # This can be removed when suggested_area is removed from DeviceEntry + device_dict.pop("_suggested_area") + device_dict.pop("is_new", None) device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 5f74da954a0..0e1e89cf1e3 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -1,13 +1,13 @@ { "domain": "enphase_envoy", "name": "Enphase Envoy", - "codeowners": ["@bdraco", "@cgarwood", "@joostlek", "@catsmanac"], + "codeowners": ["@bdraco", "@cgarwood", "@catsmanac"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.1.0"], + "requirements": ["pyenphase==2.3.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 91e93d9c59b..6e8e48d684b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -165,6 +165,7 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) else: # If no enpower device assign numbers to Envoy itself diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 42b47e5d793..358275942ca 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -223,6 +223,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) else: # If no enpower device assign selects to Envoy itself diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index c1088252618..e771233b069 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING from pyenphase import ( EnvoyACBPower, EnvoyBatteryAggregate, + EnvoyC6CC, + EnvoyCollar, EnvoyEncharge, EnvoyEnchargeAggregate, EnvoyEnchargePower, @@ -790,6 +792,58 @@ ENPOWER_SENSORS = ( ) +@dataclass(frozen=True, kw_only=True) +class EnvoyCollarSensorEntityDescription(SensorEntityDescription): + """Describes an Envoy Collar sensor entity.""" + + value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str] + + +COLLAR_SENSORS = ( + EnvoyCollarSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=attrgetter("temperature"), + ), + EnvoyCollarSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date), + ), + EnvoyCollarSensorEntityDescription( + key="grid_state", + translation_key="grid_status", + value_fn=lambda collar: collar.grid_state, + ), + EnvoyCollarSensorEntityDescription( + key="mid_state", + translation_key="mid_state", + value_fn=lambda collar: collar.mid_state, + ), +) + + +@dataclass(frozen=True, kw_only=True) +class EnvoyC6CCSensorEntityDescription(SensorEntityDescription): + """Describes an Envoy C6 Combiner controller sensor entity.""" + + value_fn: Callable[[EnvoyC6CC], datetime.datetime] + + +C6CC_SENSORS = ( + EnvoyC6CCSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda c6cc: dt_util.utc_from_timestamp(c6cc.last_report_date), + ), +) + + @dataclass(frozen=True) class EnvoyEnchargeAggregateRequiredKeysMixin: """Mixin for required keys.""" @@ -1050,6 +1104,15 @@ async def async_setup_entry( AggregateBatteryEntity(coordinator, description) for description in AGGREGATE_BATTERY_SENSORS ) + if envoy_data.collar: + entities.extend( + EnvoyCollarEntity(coordinator, description) + for description in COLLAR_SENSORS + ) + if envoy_data.c6cc: + entities.extend( + EnvoyC6CCEntity(coordinator, description) for description in C6CC_SENSORS + ) async_add_entities(entities) @@ -1313,6 +1376,7 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity): manufacturer="Enphase", model="Inverter", via_device=(DOMAIN, self.envoy_serial_num), + serial_number=serial_number, ) @property @@ -1356,6 +1420,7 @@ class EnvoyEnchargeEntity(EnvoySensorBaseEntity): name=f"Encharge {serial_number}", sw_version=str(encharge_inventory[self._serial_number].firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=serial_number, ) @@ -1420,6 +1485,7 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity): name=f"Enpower {enpower_data.serial_number}", sw_version=str(enpower_data.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=enpower_data.serial_number, ) @property @@ -1485,3 +1551,70 @@ class AggregateBatteryEntity(EnvoySystemSensorEntity): battery_aggregate = self.data.battery_aggregate assert battery_aggregate is not None return self.entity_description.value_fn(battery_aggregate) + + +class EnvoyCollarEntity(EnvoySensorBaseEntity): + """Envoy Collar sensor entity.""" + + entity_description: EnvoyCollarSensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyCollarSensorEntityDescription, + ) -> None: + """Initialize Collar entity.""" + super().__init__(coordinator, description) + collar_data = self.data.collar + assert collar_data is not None + self._serial_number = collar_data.serial_number + self._attr_unique_id = f"{collar_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, collar_data.serial_number)}, + manufacturer="Enphase", + model="IQ Meter Collar", + name=f"Collar {collar_data.serial_number}", + sw_version=str(collar_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=collar_data.serial_number, + ) + + @property + def native_value(self) -> datetime.datetime | int | float | str: + """Return the state of the collar sensors.""" + collar_data = self.data.collar + assert collar_data is not None + return self.entity_description.value_fn(collar_data) + + +class EnvoyC6CCEntity(EnvoySensorBaseEntity): + """Envoy C6CC sensor entity.""" + + entity_description: EnvoyC6CCSensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyC6CCSensorEntityDescription, + ) -> None: + """Initialize Encharge entity.""" + super().__init__(coordinator, description) + c6cc_data = self.data.c6cc + assert c6cc_data is not None + self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, c6cc_data.serial_number)}, + manufacturer="Enphase", + model="C6 COMBINER CONTROLLER", + name=f"C6 Combiner {c6cc_data.serial_number}", + sw_version=str(c6cc_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=c6cc_data.serial_number, + ) + + @property + def native_value(self) -> datetime.datetime: + """Return the state of the c6cc inventory sensors.""" + c6cc_data = self.data.c6cc + assert c6cc_data is not None + return self.entity_description.value_fn(c6cc_data) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 36319c71bc6..17ed8eff67e 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -363,7 +363,7 @@ "discharging": "[%key:common::state::discharging%]", "idle": "[%key:common::state::idle%]", "charging": "[%key:common::state::charging%]", - "full": "Full" + "full": "[%key:common::state::full%]" } }, "acb_available_energy": { @@ -407,6 +407,12 @@ }, "last_report_duration": { "name": "Last report duration" + }, + "grid_status": { + "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]" + }, + "mid_state": { + "name": "MID state" } }, "switch": { diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index bb4ed874b1d..02736979e66 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -138,6 +138,7 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) @property @@ -235,6 +236,7 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) else: # If no enpower device assign switches to Envoy itself diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 62128077f2f..472384fdf7d 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"] + "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"] } diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index ad455e620bb..70756c31f0f 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -100,49 +100,70 @@ class EsphomeAlarmControlPanel( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.DISARM, code + self._key, + AlarmControlPanelCommand.DISARM, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_HOME, code + self._key, + AlarmControlPanelCommand.ARM_HOME, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_AWAY, code + self._key, + AlarmControlPanelCommand.ARM_AWAY, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_NIGHT, code + self._key, + AlarmControlPanelCommand.ARM_NIGHT, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code + self._key, + AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_VACATION, code + self._key, + AlarmControlPanelCommand.ARM_VACATION, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.TRIGGER, code + self._key, + AlarmControlPanelCommand.TRIGGER, + code, + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index f6367165400..adddacd3998 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -284,11 +284,15 @@ class EsphomeAssistSatellite( assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: - data_to_send = { - "tts_start_streaming": "1" - if (event.data and event.data.get("tts_start_streaming")) - else "0", - } + if ( + not event.data + or ("tts_start_streaming" not in event.data) + or (not event.data["tts_start_streaming"]) + ): + # ESPHome only needs to know if early TTS streaming is available + return + + data_to_send = {"tts_start_streaming": "1"} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 31121d98ff7..795a4bc4ed8 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -48,7 +48,7 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): @convert_api_error_ha_error async def async_press(self) -> None: """Press the button.""" - self._client.button_command(self._key) + self._client.button_command(self._key, device_id=self._static_info.device_id) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 667d5d00154..927ea87e0bf 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -287,18 +287,24 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW] if ATTR_TARGET_TEMP_HIGH in kwargs: data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] - self._client.climate_command(**data) + self._client.climate_command(**data, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - self._client.climate_command(key=self._key, target_humidity=humidity) + self._client.climate_command( + key=self._key, + target_humidity=humidity, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" self._client.climate_command( - key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode) + key=self._key, + mode=_CLIMATE_MODES.from_hass(hvac_mode), + device_id=self._static_info.device_id, ) @convert_api_error_ha_error @@ -309,7 +315,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["custom_preset"] = preset_mode else: kwargs["preset"] = _PRESETS.from_hass(preset_mode) - self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -319,13 +325,15 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["custom_fan_mode"] = fan_mode else: kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) - self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" self._client.climate_command( - key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) + key=self._key, + swing_mode=_SWING_MODES.from_hass(swing_mode), + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 75408246e78..4efb0e494ef 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -51,6 +51,7 @@ from .const import ( DOMAIN, ) from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info +from .encryption_key_storage import async_get_encryption_key_storage from .entry_data import ESPHomeConfigEntry from .manager import async_replace_device @@ -159,7 +160,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle reauthorization flow.""" errors = {} - if await self._retrieve_encryption_key_from_dashboard(): + if ( + await self._retrieve_encryption_key_from_storage() + or await self._retrieve_encryption_key_from_dashboard() + ): error = await self.fetch_device_info() if error is None: return await self._async_authenticate_or_add() @@ -226,9 +230,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): response = await self.fetch_device_info() self._noise_psk = None + # Try to retrieve an existing key from dashboard or storage. if ( self._device_name and await self._retrieve_encryption_key_from_dashboard() + ) or ( + self._device_mac and await self._retrieve_encryption_key_from_storage() ): response = await self.fetch_device_info() @@ -284,6 +291,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._name = discovery_info.properties.get("friendly_name", device_name) self._host = discovery_info.host self._port = discovery_info.port + self._device_mac = mac_address self._noise_required = bool(discovery_info.properties.get("api_encryption")) # Check if already configured @@ -308,10 +316,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Don't call _fetch_device_info() for ignored entries raise AbortFlow("already_configured") configured_host: str | None = entry.data.get(CONF_HOST) - configured_port: int | None = entry.data.get(CONF_PORT) - if configured_host == host and configured_port == port: + configured_port: int = entry.data.get(CONF_PORT, DEFAULT_PORT) + # When port is None (from DHCP discovery), only compare hosts + if configured_host == host and (port is None or configured_port == port): # Don't probe to verify the mac is correct since - # the host and port matches. + # the host matches (and port matches if provided). raise AbortFlow("already_configured") configured_psk: str | None = entry.data.get(CONF_NOISE_PSK) await self._fetch_device_info(host, port or configured_port, configured_psk) @@ -772,6 +781,26 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._noise_psk = noise_psk return True + async def _retrieve_encryption_key_from_storage(self) -> bool: + """Try to retrieve the encryption key from storage. + + Return boolean if a key was retrieved. + """ + # Try to get MAC address from current flow state or reauth entry + mac_address = self._device_mac + if mac_address is None and self._reauth_entry is not None: + # In reauth flow, get MAC from the existing entry's unique_id + mac_address = self._reauth_entry.unique_id + + assert mac_address is not None + + storage = await async_get_encryption_key_storage(self.hass) + if stored_key := await storage.async_get_key(mac_address): + self._noise_psk = stored_key + return True + + return False + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 2c9bee32734..385c88d6eb9 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -17,7 +17,7 @@ DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False DEFAULT_PORT: Final = 6053 -STABLE_BLE_VERSION_STR = "2025.5.0" +STABLE_BLE_VERSION_STR = "2025.8.0" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 4426724e3f4..f9ff944809a 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -90,38 +90,56 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @convert_api_error_ha_error async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._client.cover_command(key=self._key, position=1.0) + self._client.cover_command( + key=self._key, position=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - self._client.cover_command(key=self._key, position=0.0) + self._client.cover_command( + key=self._key, position=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._client.cover_command(key=self._key, stop=True) + self._client.cover_command( + key=self._key, stop=True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - self._client.cover_command(key=self._key, position=kwargs[ATTR_POSITION] / 100) + self._client.cover_command( + key=self._key, + position=kwargs[ATTR_POSITION] / 100, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - self._client.cover_command(key=self._key, tilt=1.0) + self._client.cover_command( + key=self._key, tilt=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - self._client.cover_command(key=self._key, tilt=0.0) + self._client.cover_command( + key=self._key, tilt=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position: int = kwargs[ATTR_TILT_POSITION] - self._client.cover_command(key=self._key, tilt=tilt_position / 100) + self._client.cover_command( + key=self._key, + tilt=tilt_position / 100, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/date.py b/homeassistant/components/esphome/date.py index ef446cceac6..fc125067553 100644 --- a/homeassistant/components/esphome/date.py +++ b/homeassistant/components/esphome/date.py @@ -28,7 +28,13 @@ class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity): async def async_set_value(self, value: date) -> None: """Update the current date.""" - self._client.date_command(self._key, value.year, value.month, value.day) + self._client.date_command( + self._key, + value.year, + value.month, + value.day, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index 3ea285fa849..46c5c2da2d8 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -29,7 +29,9 @@ class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity async def async_set_value(self, value: datetime) -> None: """Update the current datetime.""" - self._client.datetime_command(self._key, int(value.timestamp())) + self._client.datetime_command( + self._key, int(value.timestamp()), device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/encryption_key_storage.py b/homeassistant/components/esphome/encryption_key_storage.py new file mode 100644 index 00000000000..e4b5ef41c2e --- /dev/null +++ b/homeassistant/components/esphome/encryption_key_storage.py @@ -0,0 +1,94 @@ +"""Encryption key storage for ESPHome devices.""" + +from __future__ import annotations + +import logging +from typing import TypedDict + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey + +_LOGGER = logging.getLogger(__name__) + +ENCRYPTION_KEY_STORAGE_VERSION = 1 +ENCRYPTION_KEY_STORAGE_KEY = "esphome.encryption_keys" + + +class EncryptionKeyData(TypedDict): + """Encryption key storage data.""" + + keys: dict[str, str] # MAC address -> base64 encoded key + + +KEY_ENCRYPTION_STORAGE: HassKey[ESPHomeEncryptionKeyStorage] = HassKey( + "esphome_encryption_key_storage" +) + + +class ESPHomeEncryptionKeyStorage: + """Storage for ESPHome encryption keys.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the encryption key storage.""" + self.hass = hass + self._store = Store[EncryptionKeyData]( + hass, + ENCRYPTION_KEY_STORAGE_VERSION, + ENCRYPTION_KEY_STORAGE_KEY, + encoder=JSONEncoder, + ) + self._data: EncryptionKeyData | None = None + + async def async_load(self) -> None: + """Load encryption keys from storage.""" + if self._data is None: + data = await self._store.async_load() + self._data = data or {"keys": {}} + + async def async_save(self) -> None: + """Save encryption keys to storage.""" + if self._data is not None: + await self._store.async_save(self._data) + + async def async_get_key(self, mac_address: str) -> str | None: + """Get encryption key for a MAC address.""" + await self.async_load() + assert self._data is not None + return self._data["keys"].get(mac_address.lower()) + + async def async_store_key(self, mac_address: str, key: str) -> None: + """Store encryption key for a MAC address.""" + await self.async_load() + assert self._data is not None + self._data["keys"][mac_address.lower()] = key + await self.async_save() + _LOGGER.debug( + "Stored encryption key for device with MAC %s", + mac_address, + ) + + async def async_remove_key(self, mac_address: str) -> None: + """Remove encryption key for a MAC address.""" + await self.async_load() + assert self._data is not None + lower_mac_address = mac_address.lower() + if lower_mac_address in self._data["keys"]: + del self._data["keys"][lower_mac_address] + await self.async_save() + _LOGGER.debug( + "Removed encryption key for device with MAC %s", + mac_address, + ) + + +@singleton(KEY_ENCRYPTION_STORAGE, async_=True) +async def async_get_encryption_key_storage( + hass: HomeAssistant, +) -> ESPHomeEncryptionKeyStorage: + """Get the encryption key storage instance.""" + storage = ESPHomeEncryptionKeyStorage(hass) + await storage.async_load() + return storage diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 37f8e738aee..a6267ba17a5 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine import functools +import logging import math from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast @@ -13,7 +14,6 @@ from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, - build_unique_id, ) import voluptuous as vol @@ -24,6 +24,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_platform, + entity_registry as er, ) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -32,9 +33,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN # Import config flow so that it's added to the registry -from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +from .entry_data import ( + DeviceEntityKey, + ESPHomeConfigEntry, + RuntimeEntryData, + build_device_unique_id, +) from .enum_mapper import EsphomeEnumMapper +_LOGGER = logging.getLogger(__name__) + _InfoT = TypeVar("_InfoT", bound=EntityInfo) _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") _StateT = TypeVar("_StateT", bound=EntityState) @@ -53,21 +61,111 @@ def async_static_info_updated( ) -> None: """Update entities of this platform when entities are listed.""" current_infos = entry_data.info[info_type] - new_infos: dict[int, EntityInfo] = {} + device_info = entry_data.device_info + if TYPE_CHECKING: + assert device_info is not None + new_infos: dict[DeviceEntityKey, EntityInfo] = {} add_entities: list[_EntityT] = [] + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + # Track info by (info.device_id, info.key) to properly handle entities + # moving between devices and support sub-devices with overlapping keys for info in infos: - if not current_infos.pop(info.key, None): - # Create new entity + info_key = (info.device_id, info.key) + new_infos[info_key] = info + + # Try to find existing entity - first with current device_id + old_info = current_infos.pop(info_key, None) + + # If not found, search for entity with same key but different device_id + # This handles the case where entity moved between devices + if not old_info: + for existing_device_id, existing_key in list(current_infos): + if existing_key == info.key: + # Found entity with same key but different device_id + old_info = current_infos.pop((existing_device_id, existing_key)) + break + + # Create new entity if it doesn't exist + if not old_info: entity = entity_type(entry_data, platform.domain, info, state_type) add_entities.append(entity) - new_infos[info.key] = info + continue + + # Entity exists - check if device_id has changed + if old_info.device_id == info.device_id: + continue + + # Entity has switched devices, need to migrate unique_id and handle state subscriptions + old_unique_id = build_device_unique_id(device_info.mac_address, old_info) + entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id) + + # If entity not found in registry, re-add it + # This happens when the device_id changed and the old device was deleted + if entity_id is None: + _LOGGER.info( + "Entity with old unique_id %s not found in registry after device_id " + "changed from %s to %s, re-adding entity", + old_unique_id, + old_info.device_id, + info.device_id, + ) + entity = entity_type(entry_data, platform.domain, info, state_type) + add_entities.append(entity) + continue + + updates: dict[str, Any] = {} + new_unique_id = build_device_unique_id(device_info.mac_address, info) + + # Update unique_id if it changed + if old_unique_id != new_unique_id: + updates["new_unique_id"] = new_unique_id + + # Update device assignment in registry + if info.device_id: + # Entity now belongs to a sub device + new_device = dev_reg.async_get_device( + identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")} + ) + else: + # Entity now belongs to the main device + new_device = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + + if new_device: + updates["device_id"] = new_device.id + + # Apply all registry updates at once + if updates: + ent_reg.async_update_entity(entity_id, **updates) + + # IMPORTANT: The entity's device assignment in Home Assistant is only read when the entity + # is first added. Updating the registry alone won't move the entity to the new device + # in the UI. Additionally, the entity's state subscription is tied to the old device_id, + # so it won't receive state updates for the new device_id. + # + # We must remove the old entity and re-add it to ensure: + # 1. The entity appears under the correct device in the UI + # 2. The entity's state subscription is updated to use the new device_id + _LOGGER.debug( + "Entity %s moving from device_id %s to %s", + info.key, + old_info.device_id, + info.device_id, + ) + + # Signal the existing entity to remove itself + # The entity is registered with the old device_id, so we signal with that + entry_data.async_signal_entity_removal(info_type, old_info.device_id, info.key) + + # Create new entity with the new device_id + add_entities.append(entity_type(entry_data, platform.domain, info, state_type)) # Anything still in current_infos is now gone if current_infos: - device_info = entry_data.device_info - if TYPE_CHECKING: - assert device_info is not None entry_data.async_remove_entities( hass, current_infos.values(), device_info.mac_address ) @@ -225,7 +323,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): _static_info: _InfoT _state: _StateT - _has_state: bool + _has_state: bool = False unique_id: str def __init__( @@ -244,11 +342,28 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): self._key = entity_info.key self._state_type = state_type self._on_static_info_update(entity_info) - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} - ) + + device_name = device_info.name + # Determine the device connection based on whether this entity belongs to a sub device + if entity_info.device_id: + # Entity belongs to a sub device + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}") + } + ) + # Use the pre-computed device_id_to_name mapping for O(1) lookup + device_name = entry_data.device_id_to_name.get( + entity_info.device_id, device_info.name + ) + else: + # Entity belongs to the main device + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + if entity_info.name: - self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + self.entity_id = f"{domain}.{device_name}_{entity_info.name}" else: # https://github.com/home-assistant/core/issues/132532 # If name is not set, ESPHome will use the sanitized friendly name @@ -256,7 +371,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): # as the entity_id before it is sanitized since the sanitizer # is not utf-8 aware. In this case, its always going to be # an empty string so we drop the object_id. - self.entity_id = f"{domain}.{device_info.name}" + self.entity_id = f"{domain}.{device_name}" async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -268,7 +383,10 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): ) self.async_on_remove( entry_data.async_subscribe_state_update( - self._state_type, self._key, self._on_state_update + self._static_info.device_id, + self._state_type, + self._key, + self._on_state_update, ) ) self.async_on_remove( @@ -276,8 +394,29 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): self._static_info, self._on_static_info_update ) ) + # Register to be notified when this entity should remove itself + # This happens when the entity moves to a different device + self.async_on_remove( + entry_data.async_register_entity_removal_callback( + type(self._static_info), + self._static_info.device_id, + self._key, + self._on_removal_signal, + ) + ) self._update_state_from_entry_data() + @callback + def _on_removal_signal(self) -> None: + """Handle signal to remove this entity.""" + _LOGGER.debug( + "Entity %s received removal signal due to device_id change", + self.entity_id, + ) + # Schedule the entity to be removed + # This must be done as a task since we're in a callback + self.hass.async_create_task(self.async_remove()) + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Save the static info for this entity when it changes. @@ -290,7 +429,9 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): static_info = cast(_InfoT, static_info) assert device_info self._static_info = static_info - self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) + self._attr_unique_id = build_device_unique_id( + device_info.mac_address, static_info + ) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default # https://github.com/home-assistant/core/issues/132532 # If the name is "", we need to set it to None since otherwise diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 1e6375d8caf..eddd4d523c9 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -60,7 +60,9 @@ from .const import DOMAIN from .dashboard import async_get_dashboard type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData] - +type EntityStateKey = tuple[type[EntityState], int, int] # (state_type, device_id, key) +type EntityInfoKey = tuple[type[EntityInfo], int, int] # (info_type, device_id, key) +type DeviceEntityKey = tuple[int, int] # (device_id, key) INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()} @@ -95,6 +97,22 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { } +def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str: + """Build unique ID for entity, appending @device_id if it belongs to a sub-device. + + This wrapper around build_unique_id ensures that entities belonging to sub-devices + have their device_id appended to the unique_id to handle proper migration when + entities move between devices. + """ + base_unique_id = build_unique_id(mac, entity_info) + + # If entity belongs to a sub-device, append @device_id + if entity_info.device_id: + return f"{base_unique_id}@{entity_info.device_id}" + + return base_unique_id + + class StoreData(TypedDict, total=False): """ESPHome storage data.""" @@ -121,8 +139,10 @@ class RuntimeEntryData: # When the disconnect callback is called, we mark all states # as stale so we will always dispatch a state update when the # device reconnects. This is the same format as state_subscriptions. - stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set) - info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict) + stale_state: set[EntityStateKey] = field(default_factory=set) + info: dict[type[EntityInfo], dict[DeviceEntityKey, EntityInfo]] = field( + default_factory=dict + ) services: dict[int, UserService] = field(default_factory=dict) available: bool = False expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep) @@ -131,7 +151,7 @@ class RuntimeEntryData: api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list) disconnect_callbacks: set[CALLBACK_TYPE] = field(default_factory=set) - state_subscriptions: dict[tuple[type[EntityState], int], CALLBACK_TYPE] = field( + state_subscriptions: dict[EntityStateKey, CALLBACK_TYPE] = field( default_factory=dict ) device_update_subscriptions: set[CALLBACK_TYPE] = field(default_factory=set) @@ -148,7 +168,7 @@ class RuntimeEntryData: type[EntityInfo], list[Callable[[list[EntityInfo]], None]] ] = field(default_factory=dict) entity_info_key_updated_callbacks: dict[ - tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]] + EntityInfoKey, list[Callable[[EntityInfo], None]] ] = field(default_factory=dict) original_options: dict[str, Any] = field(default_factory=dict) media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field( @@ -160,6 +180,10 @@ class RuntimeEntryData: assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field( default_factory=list ) + device_id_to_name: dict[int, str] = field(default_factory=dict) + entity_removal_callbacks: dict[EntityInfoKey, list[CALLBACK_TYPE]] = field( + default_factory=dict + ) @property def name(self) -> str: @@ -193,7 +217,7 @@ class RuntimeEntryData: callback_: Callable[[EntityInfo], None], ) -> CALLBACK_TYPE: """Register to receive callbacks when static info is updated for a specific key.""" - callback_key = (type(static_info), static_info.key) + callback_key = (type(static_info), static_info.device_id, static_info.key) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks.append(callback_) return partial(callbacks.remove, callback_) @@ -222,7 +246,9 @@ class RuntimeEntryData: ent_reg = er.async_get(hass) for info in static_infos: if entry := ent_reg.async_get_entity_id( - INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info) + INFO_TYPE_TO_PLATFORM[type(info)], + DOMAIN, + build_device_unique_id(mac, info), ): ent_reg.async_remove(entry) @@ -231,7 +257,9 @@ class RuntimeEntryData: """Call static info updated callbacks.""" callbacks = self.entity_info_key_updated_callbacks for static_info in static_infos: - for callback_ in callbacks.get((type(static_info), static_info.key), ()): + for callback_ in callbacks.get( + (type(static_info), static_info.device_id, static_info.key), () + ): callback_(static_info) async def _ensure_platforms_loaded( @@ -267,22 +295,7 @@ class RuntimeEntryData: needed_platforms.add(Platform.BINARY_SENSOR) needed_platforms.add(Platform.SELECT) - ent_reg = er.async_get(hass) - registry_get_entity = ent_reg.async_get_entity_id - for info in infos: - platform = INFO_TYPE_TO_PLATFORM[type(info)] - needed_platforms.add(platform) - # If the unique id is in the old format, migrate it - # except if they downgraded and upgraded, there might be a duplicate - # so we want to keep the one that was already there. - if ( - (old_unique_id := info.unique_id) - and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id)) - and (new_unique_id := build_unique_id(mac, info)) != old_unique_id - and not registry_get_entity(platform, DOMAIN, new_unique_id) - ): - ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) - + 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 @@ -322,12 +335,13 @@ class RuntimeEntryData: @callback def async_subscribe_state_update( self, + device_id: int, state_type: type[EntityState], state_key: int, entity_callback: CALLBACK_TYPE, ) -> CALLBACK_TYPE: """Subscribe to state updates.""" - subscription_key = (state_type, state_key) + subscription_key = (state_type, device_id, state_key) self.state_subscriptions[subscription_key] = entity_callback return partial(delitem, self.state_subscriptions, subscription_key) @@ -339,7 +353,7 @@ class RuntimeEntryData: stale_state = self.stale_state current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) - subscription_key = (state_type, key) + subscription_key = (state_type, state.device_id, key) if ( current_state == state and subscription_key not in stale_state @@ -347,7 +361,7 @@ class RuntimeEntryData: and not ( state_type is SensorState and (platform_info := self.info.get(SensorInfo)) - and (entity_info := platform_info.get(state.key)) + and (entity_info := platform_info.get((state.device_id, state.key))) and (cast(SensorInfo, entity_info)).force_update ) ): @@ -500,3 +514,26 @@ class RuntimeEntryData: """Notify listeners that the Assist satellite wake word has been set.""" for callback_ in self.assist_satellite_set_wake_word_callbacks.copy(): callback_(wake_word_id) + + @callback + def async_register_entity_removal_callback( + self, + info_type: type[EntityInfo], + device_id: int, + key: int, + callback_: CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register to receive a callback when the entity should remove itself.""" + callback_key = (info_type, device_id, key) + callbacks = self.entity_removal_callbacks.setdefault(callback_key, []) + callbacks.append(callback_) + return partial(callbacks.remove, callback_) + + @callback + def async_signal_entity_removal( + self, info_type: type[EntityInfo], device_id: int, key: int + ) -> None: + """Signal that an entity should remove itself.""" + callback_key = (info_type, device_id, key) + for callback_ in self.entity_removal_callbacks.get(callback_key, []).copy(): + callback_() diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index a4d840845a6..882cf3606e2 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -71,7 +71,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): ORDERED_NAMED_FAN_SPEEDS, percentage ) data["speed"] = named_speed - self._client.fan_command(**data) + self._client.fan_command(**data, device_id=self._static_info.device_id) async def async_turn_on( self, @@ -85,24 +85,36 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - self._client.fan_command(key=self._key, state=False) + self._client.fan_command( + key=self._key, state=False, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - self._client.fan_command(key=self._key, oscillating=oscillating) + self._client.fan_command( + key=self._key, + oscillating=oscillating, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" self._client.fan_command( - key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) + key=self._key, + direction=_FAN_DIRECTIONS.from_hass(direction), + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - self._client.fan_command(key=self._key, preset_mode=preset_mode) + self._client.fan_command( + key=self._key, + preset_mode=preset_mode, + device_id=self._static_info.device_id, + ) @property @esphome_state_property diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 3e278b5b2d6..67b8e755c87 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -280,7 +280,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # (fewest capabilities set) data["color_mode"] = _least_complex_color_mode(color_modes) - self._client.light_command(**data) + self._client.light_command(**data, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: @@ -290,7 +290,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: data["transition_length"] = kwargs[ATTR_TRANSITION] - self._client.light_command(**data) + self._client.light_command(**data, device_id=self._static_info.device_id) @property @esphome_state_property diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index cfb9af614dd..d7e65470499 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -65,18 +65,24 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): @convert_api_error_ha_error async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - self._client.lock_command(self._key, LockCommand.LOCK) + self._client.lock_command( + self._key, LockCommand.LOCK, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" code = kwargs.get(ATTR_CODE) - self._client.lock_command(self._key, LockCommand.UNLOCK, code) + self._client.lock_command( + self._key, LockCommand.UNLOCK, code, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._client.lock_command(self._key, LockCommand.OPEN) + self._client.lock_command( + self._key, LockCommand.OPEN, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b4af39586d4..74b429cdfa1 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -2,9 +2,10 @@ from __future__ import annotations -import asyncio +import base64 from functools import partial import logging +import secrets from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( @@ -13,7 +14,6 @@ from aioesphomeapi import ( APIVersion, DeviceInfo as EsphomeDeviceInfo, EncryptionPlaintextAPIError, - EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -61,13 +61,13 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template -from homeassistant.util.async_ import create_eager_task from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, + CONF_NOISE_PSK, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_URL, @@ -78,6 +78,7 @@ from .const import ( ) from .dashboard import async_get_dashboard from .domain_data import DomainData +from .encryption_key_storage import async_get_encryption_key_storage # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData @@ -85,9 +86,7 @@ from .entry_data import ESPHomeConfigEntry, RuntimeEntryData DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" if TYPE_CHECKING: - from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] - SubscribeLogsResponse, - ) + from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001 _LOGGER = logging.getLogger(__name__) @@ -423,14 +422,7 @@ class ESPHomeManager: unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): self._async_subscribe_logs(self._async_get_equivalent_log_level()) - results = await asyncio.gather( - create_eager_task(cli.device_info()), - create_eager_task(cli.list_entities_services()), - ) - - device_info: EsphomeDeviceInfo = results[0] - entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1] - entity_infos, services = entity_infos_services + device_info, entity_infos, services = await cli.device_info_and_list_entities() device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac @@ -515,6 +507,8 @@ class ESPHomeManager: assert api_version is not None, "API version must be set" entry_data.async_on_connect(device_info, api_version) + await self._handle_dynamic_encryption_key(device_info) + if device_info.name: reconnect_logic.name = device_info.name @@ -527,6 +521,11 @@ class ESPHomeManager: device_info.name, device_mac, ) + # Build device_id_to_name mapping for efficient lookup + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name or device_info.name + for sub_device in device_info.devices + } self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state() @@ -555,11 +554,11 @@ class ESPHomeManager: ) entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE) - cli.subscribe_states(entry_data.async_update_state) - cli.subscribe_service_calls(self.async_on_service_call) - cli.subscribe_home_assistant_states( - self.async_on_state_subscription, - self.async_on_state_request, + cli.subscribe_home_assistant_states_and_services( + on_state=entry_data.async_update_state, + on_service_call=self.async_on_service_call, + on_state_sub=self.async_on_state_subscription, + on_state_request=self.async_on_state_request, ) entry_data.async_save_to_store() @@ -583,7 +582,7 @@ class ESPHomeManager: # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects entry_data.stale_state = { - (type(entity_state), key) + (type(entity_state), entity_state.device_id, key) for state_dict in entry_data.state.values() for key, entity_state in state_dict.items() } @@ -613,6 +612,7 @@ class ESPHomeManager: ), ): return + if isinstance(err, InvalidEncryptionKeyAPIError): if ( (received_name := err.received_name) @@ -643,6 +643,93 @@ class ESPHomeManager: return self.entry.async_start_reauth(self.hass) + async def _handle_dynamic_encryption_key( + self, device_info: EsphomeDeviceInfo + ) -> None: + """Handle dynamic encryption keys. + + If a device reports it supports encryption, but we connected without a key, + we need to generate and store one. + """ + noise_psk: str | None = self.entry.data.get(CONF_NOISE_PSK) + if noise_psk: + # we're already connected with a noise PSK - nothing to do + return + + if not device_info.api_encryption_supported: + # device does not support encryption - nothing to do + return + + # Connected to device without key and the device supports encryption + storage = await async_get_encryption_key_storage(self.hass) + + # First check if we have a key in storage for this device + from_storage: bool = False + if self.entry.unique_id and ( + stored_key := await storage.async_get_key(self.entry.unique_id) + ): + _LOGGER.debug( + "Retrieved encryption key from storage for device %s", + self.entry.unique_id, + ) + # Use the stored key + new_key = stored_key.encode() + new_key_str = stored_key + from_storage = True + else: + # No stored key found, generate a new one + _LOGGER.debug( + "Generating new encryption key for device %s", self.entry.unique_id + ) + new_key = base64.b64encode(secrets.token_bytes(32)) + new_key_str = new_key.decode() + + try: + # Store the key on the device using the existing connection + result = await self.cli.noise_encryption_set_key(new_key) + except APIConnectionError as ex: + _LOGGER.error( + "Connection error while storing encryption key for device %s (%s): %s", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ex, + ) + return + else: + if not result: + _LOGGER.error( + "Failed to set dynamic encryption key on device %s (%s)", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ) + return + + # Key stored successfully on device + assert self.entry.unique_id is not None + + # Only store in storage if it was newly generated + if not from_storage: + await storage.async_store_key(self.entry.unique_id, new_key_str) + + # Always update config entry + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONF_NOISE_PSK: new_key_str}, + ) + + if from_storage: + _LOGGER.info( + "Set encryption key from storage on device %s (%s)", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ) + else: + _LOGGER.info( + "Generated and stored encryption key for device %s (%s)", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ) + @callback def _async_handle_logging_changed(self, _event: Event) -> None: """Handle when the logging level changes.""" @@ -751,6 +838,28 @@ def _async_setup_device_registry( device_info = entry_data.device_info if TYPE_CHECKING: assert device_info is not None + + device_registry = dr.async_get(hass) + # Build sets of valid device identifiers and connections + valid_connections = { + (dr.CONNECTION_NETWORK_MAC, format_mac(device_info.mac_address)) + } + valid_identifiers = { + (DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}") + for sub_device in device_info.devices + } + + # Remove devices that no longer exist + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + # Skip devices we want to keep + if ( + device.connections & valid_connections + or device.identifiers & valid_identifiers + ): + continue + # Remove everything else + device_registry.async_remove_device(device.id) + sw_version = device_info.esphome_version if device_info.compilation_time: sw_version += f" ({device_info.compilation_time})" @@ -779,11 +888,14 @@ def _async_setup_device_registry( f"{device_info.project_version} (ESPHome {device_info.esphome_version})" ) - suggested_area = None - if device_info.suggested_area: + suggested_area: str | None = None + if device_info.area and device_info.area.name: + # Prefer device_info.area over suggested_area when area name is not empty + suggested_area = device_info.area.name + elif device_info.suggested_area: suggested_area = device_info.suggested_area - device_registry = dr.async_get(hass) + # Create/update main device device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, configuration_url=configuration_url, @@ -794,6 +906,36 @@ def _async_setup_device_registry( sw_version=sw_version, suggested_area=suggested_area, ) + + # Handle sub devices + # Find available areas from device_info + areas_by_id = {area.area_id: area for area in device_info.areas} + # Add the main device's area if it exists + if device_info.area: + areas_by_id[device_info.area.area_id] = device_info.area + # Create/update sub devices that should exist + for sub_device in device_info.devices: + # Determine the area for this sub device + sub_device_suggested_area: str | None = None + if sub_device.area_id is not None and sub_device.area_id in areas_by_id: + sub_device_suggested_area = areas_by_id[sub_device.area_id].name + + sub_device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")}, + name=sub_device.name or device_entry.name, + manufacturer=manufacturer, + model=model, + sw_version=sw_version, + suggested_area=sub_device_suggested_area, + ) + + # Update the sub device to set via_device_id + device_registry.async_update_device( + sub_device_entry.id, + via_device_id=device_entry.id, + ) + return device_entry.id diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 68bc8fe040e..ffb02571742 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -2,7 +2,7 @@ "domain": "esphome", "name": "ESPHome", "after_dependencies": ["hassio", "zeroconf", "tag"], - "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], + "codeowners": ["@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], "dhcp": [ @@ -17,9 +17,9 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==33.1.1", + "aioesphomeapi==39.0.0", "esphome-dashboard-api==1.3.0", - "bleak-esphome==2.16.0" + "bleak-esphome==3.1.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f18b5e7bf5c..a35d93c9fe1 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -10,6 +10,7 @@ from urllib.parse import urlparse from aioesphomeapi import ( EntityInfo, MediaPlayerCommand, + MediaPlayerEntityFeature as EspMediaPlayerEntityFeature, MediaPlayerEntityState, MediaPlayerFormatPurpose, MediaPlayerInfo, @@ -50,9 +51,36 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM EspMediaPlayerState.IDLE: MediaPlayerState.IDLE, EspMediaPlayerState.PLAYING: MediaPlayerState.PLAYING, EspMediaPlayerState.PAUSED: MediaPlayerState.PAUSED, + EspMediaPlayerState.OFF: MediaPlayerState.OFF, + EspMediaPlayerState.ON: MediaPlayerState.ON, } ) +_FEATURES = { + EspMediaPlayerEntityFeature.PAUSE: MediaPlayerEntityFeature.PAUSE, + EspMediaPlayerEntityFeature.SEEK: MediaPlayerEntityFeature.SEEK, + EspMediaPlayerEntityFeature.VOLUME_SET: MediaPlayerEntityFeature.VOLUME_SET, + EspMediaPlayerEntityFeature.VOLUME_MUTE: MediaPlayerEntityFeature.VOLUME_MUTE, + EspMediaPlayerEntityFeature.PREVIOUS_TRACK: MediaPlayerEntityFeature.PREVIOUS_TRACK, + EspMediaPlayerEntityFeature.NEXT_TRACK: MediaPlayerEntityFeature.NEXT_TRACK, + EspMediaPlayerEntityFeature.TURN_ON: MediaPlayerEntityFeature.TURN_ON, + EspMediaPlayerEntityFeature.TURN_OFF: MediaPlayerEntityFeature.TURN_OFF, + EspMediaPlayerEntityFeature.PLAY_MEDIA: MediaPlayerEntityFeature.PLAY_MEDIA, + EspMediaPlayerEntityFeature.VOLUME_STEP: MediaPlayerEntityFeature.VOLUME_STEP, + EspMediaPlayerEntityFeature.SELECT_SOURCE: MediaPlayerEntityFeature.SELECT_SOURCE, + EspMediaPlayerEntityFeature.STOP: MediaPlayerEntityFeature.STOP, + EspMediaPlayerEntityFeature.CLEAR_PLAYLIST: MediaPlayerEntityFeature.CLEAR_PLAYLIST, + EspMediaPlayerEntityFeature.PLAY: MediaPlayerEntityFeature.PLAY, + EspMediaPlayerEntityFeature.SHUFFLE_SET: MediaPlayerEntityFeature.SHUFFLE_SET, + EspMediaPlayerEntityFeature.SELECT_SOUND_MODE: MediaPlayerEntityFeature.SELECT_SOUND_MODE, + EspMediaPlayerEntityFeature.BROWSE_MEDIA: MediaPlayerEntityFeature.BROWSE_MEDIA, + EspMediaPlayerEntityFeature.REPEAT_SET: MediaPlayerEntityFeature.REPEAT_SET, + EspMediaPlayerEntityFeature.GROUPING: MediaPlayerEntityFeature.GROUPING, + EspMediaPlayerEntityFeature.MEDIA_ANNOUNCE: MediaPlayerEntityFeature.MEDIA_ANNOUNCE, + EspMediaPlayerEntityFeature.MEDIA_ENQUEUE: MediaPlayerEntityFeature.MEDIA_ENQUEUE, + EspMediaPlayerEntityFeature.SEARCH_MEDIA: MediaPlayerEntityFeature.SEARCH_MEDIA, +} + ATTR_BYPASS_PROXY = "bypass_proxy" @@ -67,16 +95,12 @@ class EsphomeMediaPlayer( def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" super()._on_static_info_update(static_info) - flags = ( - MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.BROWSE_MEDIA - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + esp_flags = EspMediaPlayerEntityFeature( + self._static_info.feature_flags_compat(self._api_version) ) - if self._static_info.supports_pause: - flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY + flags = MediaPlayerEntityFeature(0) + for espflag in esp_flags: + flags |= _FEATURES[espflag] self._attr_supported_features = flags self._entry_data.media_player_formats[self.unique_id] = cast( MediaPlayerInfo, static_info @@ -132,7 +156,10 @@ class EsphomeMediaPlayer( media_id = proxy_url self._client.media_player_command( - self._key, media_url=media_id, announcement=announcement + self._key, + media_url=media_id, + announcement=announcement, + device_id=self._static_info.device_id, ) async def async_will_remove_from_hass(self) -> None: @@ -214,22 +241,36 @@ class EsphomeMediaPlayer( @convert_api_error_ha_error async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._client.media_player_command(self._key, volume=volume) + self._client.media_player_command( + self._key, volume=volume, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_media_pause(self) -> None: """Send pause command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.PAUSE) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.PAUSE, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_media_play(self) -> None: """Send play command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.PLAY) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.PLAY, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_media_stop(self) -> None: """Send stop command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.STOP) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.STOP, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_mute_volume(self, mute: bool) -> None: @@ -237,6 +278,25 @@ class EsphomeMediaPlayer( self._client.media_player_command( self._key, command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE, + device_id=self._static_info.device_id, + ) + + @convert_api_error_ha_error + async def async_turn_on(self) -> None: + """Send turn on command.""" + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.TURN_ON, + device_id=self._static_info.device_id, + ) + + @convert_api_error_ha_error + async def async_turn_off(self) -> None: + """Send turn off command.""" + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.TURN_OFF, + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 4a6800e1041..59788eb6e1f 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -67,7 +67,9 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): @convert_api_error_ha_error async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - self._client.number_command(self._key, value) + self._client.number_command( + self._key, value, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index d5451f69f0f..3834e4251ea 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -76,7 +76,9 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): @convert_api_error_ha_error async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._client.select_command(self._key, option) + self._client.select_command( + self._key, option, device_id=self._static_info.device_id + ) class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 5baa092613b..de0f07b94c9 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -81,6 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): # if the string is empty if unit_of_measurement := static_info.unit_of_measurement: self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_suggested_display_precision = static_info.accuracy_decimals self._attr_device_class = try_parse_enum( SensorDeviceClass, static_info.device_class ) @@ -97,7 +98,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) @property - def native_value(self) -> datetime | str | None: + def native_value(self) -> datetime | int | float | None: """Return the state of the entity.""" if not self._has_state or (state := self._state).missing_state: return None @@ -106,7 +107,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return None if self.device_class is SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(state_float) - return f"{state_float:.{self._static_info.accuracy_decimals}f}" + return state_float class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 35edbf678ad..7e5223ae548 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -43,12 +43,16 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): @convert_api_error_ha_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self._client.switch_command(self._key, True) + self._client.switch_command( + self._key, True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._client.switch_command(self._key, False) + self._client.switch_command( + self._key, False, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index c36621b8f4e..5ffc07ce08d 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -50,7 +50,9 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): @convert_api_error_ha_error async def async_set_value(self, value: str) -> None: """Update the current value.""" - self._client.text_command(self._key, value) + self._client.text_command( + self._key, value, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/time.py b/homeassistant/components/esphome/time.py index b0e586e1792..a416bb17a31 100644 --- a/homeassistant/components/esphome/time.py +++ b/homeassistant/components/esphome/time.py @@ -28,7 +28,13 @@ class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity): async def async_set_value(self, value: time) -> None: """Update the current time.""" - self._client.time_command(self._key, value.hour, value.minute, value.second) + self._client.time_command( + self._key, + value.hour, + value.minute, + value.second, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index cc886f2ba4c..a6d053e1c4c 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -334,11 +334,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): async def async_update(self) -> None: """Command device to check for update.""" if self.available: - self._client.update_command(key=self._key, command=UpdateCommand.CHECK) + self._client.update_command( + key=self._key, + command=UpdateCommand.CHECK, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Command device to install update.""" - self._client.update_command(key=self._key, command=UpdateCommand.INSTALL) + self._client.update_command( + key=self._key, + command=UpdateCommand.INSTALL, + device_id=self._static_info.device_id, + ) diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py index f71a253c1f1..0fe9151a5a6 100644 --- a/homeassistant/components/esphome/valve.py +++ b/homeassistant/components/esphome/valve.py @@ -72,22 +72,32 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): @convert_api_error_ha_error async def async_open_valve(self, **kwargs: Any) -> None: """Open the valve.""" - self._client.valve_command(key=self._key, position=1.0) + self._client.valve_command( + key=self._key, position=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_valve(self, **kwargs: Any) -> None: """Close valve.""" - self._client.valve_command(key=self._key, position=0.0) + self._client.valve_command( + key=self._key, position=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_stop_valve(self, **kwargs: Any) -> None: """Stop the valve.""" - self._client.valve_command(key=self._key, stop=True) + self._client.valve_command( + key=self._key, stop=True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_valve_position(self, position: float) -> None: """Move the valve to a specific position.""" - self._client.valve_command(key=self._key, position=position / 100) + self._client.valve_command( + key=self._key, + position=position / 100, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index a93954b8a9b..65749871093 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -94,8 +94,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. @@ -120,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bo return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - - -async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 622f767443d..d90f04b403a 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,7 +17,11 @@ from pyezvizapi.exceptions import ( from pyezvizapi.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -386,7 +390,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EzvizOptionsFlowHandler(OptionsFlow): +class EzvizOptionsFlowHandler(OptionsFlowWithReload): """Handle EZVIZ client options.""" async def async_step_init( diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 44f80ad6cd1..24842f45b68 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -2,9 +2,17 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pyezvizapi.constants import DeviceSwitchType, SoundMode +from pyezvizapi.constants import ( + BatteryCameraWorkMode, + DeviceCatagories, + DeviceSwitchType, + SoundMode, + SupportExt, +) from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -24,17 +32,83 @@ class EzvizSelectEntityDescription(SelectEntityDescription): """Describe a EZVIZ Select entity.""" supported_switch: int + current_option: Callable[[EzvizSelect], str | None] + select_option: Callable[[EzvizSelect, str, str], None] -SELECT_TYPE = EzvizSelectEntityDescription( +def alarm_sound_mode_current_option(ezvizSelect: EzvizSelect) -> str | None: + """Return the selected entity option to represent the entity state.""" + sound_mode_value = getattr( + SoundMode, ezvizSelect.data[ezvizSelect.entity_description.key] + ).value + if sound_mode_value in [0, 1, 2]: + return ezvizSelect.options[sound_mode_value] + + return None + + +def alarm_sound_mode_select_option( + ezvizSelect: EzvizSelect, serial: str, option: str +) -> None: + """Change the selected option.""" + sound_mode_value = ezvizSelect.options.index(option) + ezvizSelect.coordinator.ezviz_client.alarm_sound(serial, sound_mode_value, 1) + + +ALARM_SOUND_MODE_SELECT_TYPE = EzvizSelectEntityDescription( key="alarm_sound_mod", translation_key="alarm_sound_mode", entity_category=EntityCategory.CONFIG, options=["soft", "intensive", "silent"], supported_switch=DeviceSwitchType.ALARM_TONE.value, + current_option=alarm_sound_mode_current_option, + select_option=alarm_sound_mode_select_option, ) +def battery_work_mode_current_option(ezvizSelect: EzvizSelect) -> str | None: + """Return the selected entity option to represent the entity state.""" + battery_work_mode = getattr( + BatteryCameraWorkMode, + ezvizSelect.data[ezvizSelect.entity_description.key], + BatteryCameraWorkMode.UNKNOWN, + ) + if battery_work_mode == BatteryCameraWorkMode.UNKNOWN: + return None + + return battery_work_mode.name.lower() + + +def battery_work_mode_select_option( + ezvizSelect: EzvizSelect, serial: str, option: str +) -> None: + """Change the selected option.""" + battery_work_mode = getattr(BatteryCameraWorkMode, option.upper()) + ezvizSelect.coordinator.ezviz_client.set_battery_camera_work_mode( + serial, battery_work_mode.value + ) + + +BATTERY_WORK_MODE_SELECT_TYPE = EzvizSelectEntityDescription( + key="battery_camera_work_mode", + translation_key="battery_camera_work_mode", + icon="mdi:battery-sync", + entity_category=EntityCategory.CONFIG, + options=[ + "plugged_in", + "high_performance", + "power_save", + "super_power_save", + "custom", + ], + supported_switch=-1, + current_option=battery_work_mode_current_option, + select_option=battery_work_mode_select_option, +) + +SELECT_TYPES = [ALARM_SOUND_MODE_SELECT_TYPE, BATTERY_WORK_MODE_SELECT_TYPE] + + async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, @@ -43,12 +117,26 @@ async def async_setup_entry( """Set up EZVIZ select entities based on a config entry.""" coordinator = entry.runtime_data - async_add_entities( - EzvizSelect(coordinator, camera) + entities = [ + EzvizSelect(coordinator, camera, ALARM_SOUND_MODE_SELECT_TYPE) for camera in coordinator.data for switch in coordinator.data[camera]["switches"] - if switch == SELECT_TYPE.supported_switch - ) + if switch == ALARM_SOUND_MODE_SELECT_TYPE.supported_switch + ] + + for camera in coordinator.data: + device_category = coordinator.data[camera].get("device_category") + supportExt = coordinator.data[camera].get("supportExt") + if ( + device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value + and supportExt + and str(SupportExt.SupportBatteryManage.value) in supportExt + ): + entities.append( + EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE) + ) + + async_add_entities(entities) class EzvizSelect(EzvizEntity, SelectEntity): @@ -58,31 +146,23 @@ class EzvizSelect(EzvizEntity, SelectEntity): self, coordinator: EzvizDataUpdateCoordinator, serial: str, + description: EzvizSelectEntityDescription, ) -> None: - """Initialize the sensor.""" + """Initialize the select entity.""" super().__init__(coordinator, serial) - self._attr_unique_id = f"{serial}_{SELECT_TYPE.key}" - self.entity_description = SELECT_TYPE + self._attr_unique_id = f"{serial}_{description.key}" + self.entity_description = description @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - sound_mode_value = getattr( - SoundMode, self.data[self.entity_description.key] - ).value - if sound_mode_value in [0, 1, 2]: - return self.options[sound_mode_value] - - return None + desc = cast(EzvizSelectEntityDescription, self.entity_description) + return desc.current_option(self) def select_option(self, option: str) -> None: """Change the selected option.""" - sound_mode_value = self.options.index(option) - + desc = cast(EzvizSelectEntityDescription, self.entity_description) try: - self.coordinator.ezviz_client.alarm_sound(self._serial, sound_mode_value, 1) - + return desc.select_option(self, self._serial, option) except (HTTPError, PyEzvizError) as err: - raise HomeAssistantError( - f"Cannot set Warning sound level for {self.entity_id}" - ) from err + raise HomeAssistantError(f"Cannot select option for {desc.key}") from err diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index c441b34b42d..ec631e8e5c1 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -66,6 +66,26 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { key="last_alarm_type_name", translation_key="last_alarm_type_name", ), + "Record_Mode": SensorEntityDescription( + key="Record_Mode", + translation_key="record_mode", + entity_registry_enabled_default=False, + ), + "battery_camera_work_mode": SensorEntityDescription( + key="battery_camera_work_mode", + translation_key="battery_camera_work_mode", + entity_registry_enabled_default=False, + ), + "powerStatus": SensorEntityDescription( + key="powerStatus", + translation_key="power_status", + entity_registry_enabled_default=False, + ), + "OnlineStatus": SensorEntityDescription( + key="OnlineStatus", + translation_key="online_status", + entity_registry_enabled_default=False, + ), } @@ -76,16 +96,26 @@ async def async_setup_entry( ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data + entities: list[EzvizSensor] = [] - async_add_entities( - [ + for camera, sensors in coordinator.data.items(): + entities.extend( EzvizSensor(coordinator, camera, sensor) - for camera in coordinator.data - for sensor, value in coordinator.data[camera].items() - if sensor in SENSOR_TYPES - if value is not None - ] - ) + for sensor, value in sensors.items() + if sensor in SENSOR_TYPES and value is not None + ) + + optionals = sensors.get("optionals", {}) + entities.extend( + EzvizSensor(coordinator, camera, optional_key) + for optional_key in ("powerStatus", "OnlineStatus") + if optional_key in optionals + ) + + if "mode" in optionals.get("Record_Mode", {}): + entities.append(EzvizSensor(coordinator, camera, "mode")) + + async_add_entities(entities) class EzvizSensor(EzvizEntity, SensorEntity): diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index cd8bbc9d199..ad8f7114407 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -68,6 +68,16 @@ "intensive": "Intensive", "silent": "Silent" } + }, + "battery_camera_work_mode": { + "name": "Battery work mode", + "state": { + "plugged_in": "Plugged in", + "high_performance": "High performance", + "power_save": "Power save", + "super_power_save": "Super power saving", + "custom": "Custom" + } } }, "image": { @@ -137,6 +147,18 @@ }, "last_alarm_type_name": { "name": "Last alarm type name" + }, + "record_mode": { + "name": "Record mode" + }, + "battery_camera_work_mode": { + "name": "Battery work mode" + }, + "power_status": { + "name": "Power status" + }, + "online_status": { + "name": "Online status" } }, "switch": { diff --git a/homeassistant/components/fan/intent.py b/homeassistant/components/fan/intent.py new file mode 100644 index 00000000000..ef088a4bba9 --- /dev/null +++ b/homeassistant/components/fan/intent.py @@ -0,0 +1,31 @@ +"""Intents for the fan integration.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import ATTR_PERCENTAGE, DOMAIN, SERVICE_TURN_ON + +INTENT_FAN_SET_SPEED = "HassFanSetSpeed" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the fan intents.""" + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_FAN_SET_SPEED, + DOMAIN, + SERVICE_TURN_ON, + description="Sets a fan's speed by percentage", + required_domains={DOMAIN}, + platforms={DOMAIN}, + required_slots={ + ATTR_PERCENTAGE: intent.IntentSlotInfo( + description="The speed percentage of the fan", + value_schema=vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + ) + }, + ), + ) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 57c58d3a2b1..9acec01ee6d 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -32,8 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) - await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -46,10 +44,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) if len(entries) == 1: hass.data.pop(MY_KEY) return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: FeedReaderConfigEntry -) -> None: - """Handle reconfiguration.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 3d0fec1a6f5..37c627f21ba 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback @@ -44,7 +44,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> FeedReaderOptionsFlowHandler: """Get the options flow for this handler.""" return FeedReaderOptionsFlowHandler() @@ -119,11 +119,10 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): errors={"base": "url_error"}, ) - self.hass.config_entries.async_update_entry(reconfigure_entry, data=user_input) - return self.async_abort(reason="reconfigure_successful") + return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class FeedReaderOptionsFlowHandler(OptionsFlow): +class FeedReaderOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 7bc206057c8..59a08715b8e 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups( entry, [Platform(entry.data[CONF_PLATFORM])] ) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -41,11 +40,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate config entry.""" if config_entry.version > 2: diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 1c4fdbe5c84..9078a4d115e 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_FILE_PATH, @@ -131,7 +131,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_handle_step(Platform.SENSOR.value, user_input) -class FileOptionsFlowHandler(OptionsFlow): +class FileOptionsFlowHandler(OptionsFlowWithReload): """Handle File options.""" async def async_step_init( diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 296bcaac68d..f96edbc0f71 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -19,7 +19,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="chlorine", translation_key="chlorine", - native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + native_unit_of_measurement="mg/L", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 171341f7226..7b534b80500 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -47,8 +47,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - return True @@ -57,10 +55,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options( - hass: HomeAssistant, entry: ForecastSolarConfigEntry -) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 9a64ce6e1fb..031764a0d0a 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback @@ -88,7 +88,7 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): ) -class ForecastSolarOptionFlowHandler(OptionsFlow): +class ForecastSolarOptionFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 222a7e44a45..099123ccd9b 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -30,7 +30,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bo verbose=False, ) coordinator = FoscamCoordinator(hass, entry, session) - await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -89,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> async def async_migrate_entities(hass: HomeAssistant, entry: FoscamConfigEntry) -> None: - """Migrate old entry.""" + """Migrate old entries to support config_entry_id-based unique IDs.""" @callback def _update_unique_id( diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index 562c3f42f8b..93ec5f909c4 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -26,7 +26,7 @@ from .const import CONF_RTSP_PORT, CONF_STREAM, DOMAIN, LOGGER STREAMS = ["Main", "Sub"] DEFAULT_PORT = 88 -DEFAULT_RTSP_PORT = 554 +DEFAULT_RTSP_PORT = 88 DATA_SCHEMA = vol.Schema( diff --git a/homeassistant/components/foscam/const.py b/homeassistant/components/foscam/const.py index 38088cf3f6f..33c1b31aeec 100644 --- a/homeassistant/components/foscam/const.py +++ b/homeassistant/components/foscam/const.py @@ -11,3 +11,16 @@ CONF_STREAM = "stream" SERVICE_PTZ = "ptz" SERVICE_PTZ_PRESET = "ptz_preset" + +SUPPORTED_SWITCHES = [ + "flip_switch", + "mirror_switch", + "ir_switch", + "sleep_switch", + "white_light_switch", + "siren_alarm_switch", + "turn_off_volume_switch", + "light_status_switch", + "hdr_switch", + "wdr_switch", +] diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 72bf60cffe0..50ddd76ddb3 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -1,8 +1,8 @@ """The foscam coordinator object.""" import asyncio +from dataclasses import dataclass from datetime import timedelta -from typing import Any from libpyfoscamcgi import FoscamCamera @@ -15,9 +15,35 @@ from .const import DOMAIN, LOGGER type FoscamConfigEntry = ConfigEntry[FoscamCoordinator] -class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): +@dataclass +class FoscamDeviceInfo: + """A data class representing the current state and configuration of a Foscam camera device.""" + + dev_info: dict + product_info: dict + + is_open_ir: bool + is_flip: bool + is_mirror: bool + + is_asleep: dict + is_open_white_light: bool + is_siren_alarm: bool + + volume: int + speak_volume: int + is_turn_off_volume: bool + is_turn_off_light: bool + + is_open_wdr: bool | None = None + is_open_hdr: bool | None = None + + +class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]): """Foscam coordinator.""" + config_entry: FoscamConfigEntry + def __init__( self, hass: HomeAssistant, @@ -34,24 +60,82 @@ class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.session = session - async def _async_update_data(self) -> dict[str, Any]: + def gather_all_configs(self) -> FoscamDeviceInfo: + """Get all Foscam configurations.""" + ret_dev_info, dev_info = self.session.get_dev_info() + dev_info = dev_info if ret_dev_info == 0 else {} + + ret_product_info, product_info = self.session.get_product_all_info() + product_info = product_info if ret_product_info == 0 else {} + + ret_ir, infra_led_config = self.session.get_infra_led_config() + is_open_ir = infra_led_config["mode"] == "1" if ret_ir == 0 else False + + ret_mf, mirror_flip_setting = self.session.get_mirror_and_flip_setting() + is_flip = mirror_flip_setting["isFlip"] == "1" if ret_mf == 0 else False + is_mirror = mirror_flip_setting["isMirror"] == "1" if ret_mf == 0 else False + + ret_sleep, sleep_setting = self.session.is_asleep() + is_asleep = {"supported": ret_sleep == 0, "status": bool(int(sleep_setting))} + + ret_wl, is_open_white_light = self.session.getWhiteLightBrightness() + is_open_white_light_val = ( + is_open_white_light["enable"] == "1" if ret_wl == 0 else False + ) + + ret_sc, is_siren_alarm = self.session.getSirenConfig() + is_siren_alarm_val = ( + is_siren_alarm["sirenEnable"] == "1" if ret_sc == 0 else False + ) + + ret_vol, volume = self.session.getAudioVolume() + volume_val = int(volume["volume"]) if ret_vol == 0 else 0 + + ret_sv, speak_volume = self.session.getSpeakVolume() + speak_volume_val = int(speak_volume["SpeakVolume"]) if ret_sv == 0 else 0 + + ret_ves, is_turn_off_volume = self.session.getVoiceEnableState() + is_turn_off_volume_val = not ( + ret_ves == 0 and is_turn_off_volume["isEnable"] == "1" + ) + + ret_les, is_turn_off_light = self.session.getLedEnableState() + is_turn_off_light_val = not ( + ret_les == 0 and is_turn_off_light["isEnable"] == "0" + ) + + is_open_wdr = None + is_open_hdr = None + reserve3 = product_info.get("reserve3") + reserve3_int = int(reserve3) if reserve3 is not None else 0 + + if (reserve3_int & (1 << 8)) != 0: + ret_wdr, is_open_wdr_data = self.session.getWdrMode() + mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 + is_open_wdr = bool(int(mode)) + else: + ret_hdr, is_open_hdr_data = self.session.getHdrMode() + mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 + is_open_hdr = bool(int(mode)) + + return FoscamDeviceInfo( + dev_info=dev_info, + product_info=product_info, + is_open_ir=is_open_ir, + is_flip=is_flip, + is_mirror=is_mirror, + is_asleep=is_asleep, + is_open_white_light=is_open_white_light_val, + is_siren_alarm=is_siren_alarm_val, + volume=volume_val, + speak_volume=speak_volume_val, + is_turn_off_volume=is_turn_off_volume_val, + is_turn_off_light=is_turn_off_light_val, + is_open_wdr=is_open_wdr, + is_open_hdr=is_open_hdr, + ) + + async def _async_update_data(self) -> FoscamDeviceInfo: """Fetch data from API endpoint.""" - - async with asyncio.timeout(30): - data = {} - ret, dev_info = await self.hass.async_add_executor_job( - self.session.get_dev_info - ) - if ret == 0: - data["dev_info"] = dev_info - - all_info = await self.hass.async_add_executor_job( - self.session.get_product_all_info - ) - data["product_info"] = all_info[1] - - ret, is_asleep = await self.hass.async_add_executor_job( - self.session.is_asleep - ) - data["is_asleep"] = {"supported": ret == 0, "status": is_asleep} - return data + async with asyncio.timeout(10): + return await self.hass.async_add_executor_job(self.gather_all_configs) diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py index e9d1bbbe176..7bc983cbfaa 100644 --- a/homeassistant/components/foscam/entity.py +++ b/homeassistant/components/foscam/entity.py @@ -13,19 +13,15 @@ from .coordinator import FoscamCoordinator class FoscamEntity(CoordinatorEntity[FoscamCoordinator]): """Base entity for Foscam camera.""" - def __init__( - self, - coordinator: FoscamCoordinator, - entry_id: str, - ) -> None: + def __init__(self, coordinator: FoscamCoordinator, config_entry_id: str) -> None: """Initialize the base Foscam entity.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry_id)}, + identifiers={(DOMAIN, config_entry_id)}, manufacturer="Foscam", ) - if dev_info := coordinator.data.get("dev_info"): + if dev_info := coordinator.data.dev_info: self._attr_device_info[ATTR_MODEL] = dev_info["productName"] self._attr_device_info[ATTR_SW_VERSION] = dev_info["firmwareVer"] self._attr_device_info[ATTR_HW_VERSION] = dev_info["hardwareVer"] diff --git a/homeassistant/components/foscam/icons.json b/homeassistant/components/foscam/icons.json index 437575024d1..4b0b0c17c32 100644 --- a/homeassistant/components/foscam/icons.json +++ b/homeassistant/components/foscam/icons.json @@ -6,5 +6,39 @@ "ptz_preset": { "service": "mdi:target-variant" } + }, + "entity": { + "switch": { + "flip_switch": { + "default": "mdi:flip-vertical" + }, + "mirror_switch": { + "default": "mdi:mirror" + }, + "ir_switch": { + "default": "mdi:theme-light-dark" + }, + "sleep_switch": { + "default": "mdi:sleep" + }, + "white_light_switch": { + "default": "mdi:light-flood-down" + }, + "siren_alarm_switch": { + "default": "mdi:alarm-note" + }, + "turn_off_volume_switch": { + "default": "mdi:volume-off" + }, + "turn_off_light_switch": { + "default": "mdi:lightbulb-fluorescent-tube" + }, + "hdr_switch": { + "default": "mdi:hdr" + }, + "wdr_switch": { + "default": "mdi:alpha-w-box" + } + } } } diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index 9e6864cf1c6..87112199b0f 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", "loggers": ["libpyfoscamcgi"], - "requirements": ["libpyfoscamcgi==0.0.6"] + "requirements": ["libpyfoscamcgi==0.0.7"] } diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 03351e3238f..d73833b1cae 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -11,7 +11,12 @@ "stream": "Stream" }, "data_description": { - "host": "The hostname or IP address of your Foscam camera." + "host": "The hostname or IP address of your Foscam camera.", + "port": "The port of your Foscam camera, default is 88.", + "username": "The username to log in to your Foscam camera.", + "password": "The password to log in to your Foscam camera.", + "rtsp_port": "The RTSP protocol port of the camera, used to pull the camera's real-time video stream. New model cameras only support RTSP ports 88 and 554, while old model cameras only support ports 88 and 65534.", + "stream": "Select the video stream type to pull. The main stream offers higher clarity but requires a better network environment." } } }, @@ -27,8 +32,35 @@ }, "entity": { "switch": { + "flip_switch": { + "name": "Flip" + }, + "mirror_switch": { + "name": "Mirror" + }, + "ir_switch": { + "name": "Infrared mode" + }, "sleep_switch": { - "name": "Sleep" + "name": "Sleep mode" + }, + "white_light_switch": { + "name": "White light" + }, + "siren_alarm_switch": { + "name": "Siren alarm" + }, + "turn_off_volume_switch": { + "name": "Volume muted" + }, + "turn_off_light_switch": { + "name": "Light" + }, + "hdr_switch": { + "name": "HDR" + }, + "wdr_switch": { + "name": "WDR" } } }, diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 24b05b5aeaa..91118a27277 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -2,18 +2,117 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any -from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from libpyfoscamcgi import FoscamCamera + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import LOGGER from .coordinator import FoscamConfigEntry, FoscamCoordinator from .entity import FoscamEntity +def handle_ir_turn_on(session: FoscamCamera) -> None: + """Turn on IR LED: sets IR mode to auto (if supported), then turns off the IR LED.""" + + session.set_infra_led_config(1) + session.open_infra_led() + + +def handle_ir_turn_off(session: FoscamCamera) -> None: + """Turn off IR LED: sets IR mode to manual (if supported), then turns open the IR LED.""" + + session.set_infra_led_config(0) + session.close_infra_led() + + +@dataclass(frozen=True, kw_only=True) +class FoscamSwitchEntityDescription(SwitchEntityDescription): + """A custom entity description that supports a turn_off function.""" + + native_value_fn: Callable[..., bool] + turn_off_fn: Callable[[FoscamCamera], None] + turn_on_fn: Callable[[FoscamCamera], None] + + +SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [ + FoscamSwitchEntityDescription( + key="is_flip", + translation_key="flip_switch", + native_value_fn=lambda data: data.is_flip, + turn_off_fn=lambda session: session.flip_video(0), + turn_on_fn=lambda session: session.flip_video(1), + ), + FoscamSwitchEntityDescription( + key="is_mirror", + translation_key="mirror_switch", + native_value_fn=lambda data: data.is_mirror, + turn_off_fn=lambda session: session.mirror_video(0), + turn_on_fn=lambda session: session.mirror_video(1), + ), + FoscamSwitchEntityDescription( + key="is_open_ir", + translation_key="ir_switch", + native_value_fn=lambda data: data.is_open_ir, + turn_off_fn=handle_ir_turn_off, + turn_on_fn=handle_ir_turn_on, + ), + FoscamSwitchEntityDescription( + key="sleep_switch", + translation_key="sleep_switch", + native_value_fn=lambda data: data.is_asleep["status"], + turn_off_fn=lambda session: session.wake_up(), + turn_on_fn=lambda session: session.sleep(), + ), + FoscamSwitchEntityDescription( + key="is_open_white_light", + translation_key="white_light_switch", + native_value_fn=lambda data: data.is_open_white_light, + turn_off_fn=lambda session: session.closeWhiteLight(), + turn_on_fn=lambda session: session.openWhiteLight(), + ), + FoscamSwitchEntityDescription( + key="is_siren_alarm", + translation_key="siren_alarm_switch", + native_value_fn=lambda data: data.is_siren_alarm, + turn_off_fn=lambda session: session.setSirenConfig(0, 100, 0), + turn_on_fn=lambda session: session.setSirenConfig(1, 100, 0), + ), + FoscamSwitchEntityDescription( + key="is_turn_off_volume", + translation_key="turn_off_volume_switch", + native_value_fn=lambda data: data.is_turn_off_volume, + turn_off_fn=lambda session: session.setVoiceEnableState(1), + turn_on_fn=lambda session: session.setVoiceEnableState(0), + ), + FoscamSwitchEntityDescription( + key="is_turn_off_light", + translation_key="turn_off_light_switch", + native_value_fn=lambda data: data.is_turn_off_light, + turn_off_fn=lambda session: session.setLedEnableState(0), + turn_on_fn=lambda session: session.setLedEnableState(1), + ), + FoscamSwitchEntityDescription( + key="is_open_hdr", + translation_key="hdr_switch", + native_value_fn=lambda data: data.is_open_hdr, + turn_off_fn=lambda session: session.setHdrMode(0), + turn_on_fn=lambda session: session.setHdrMode(1), + ), + FoscamSwitchEntityDescription( + key="is_open_wdr", + translation_key="wdr_switch", + native_value_fn=lambda data: data.is_open_wdr, + turn_off_fn=lambda session: session.setWdrMode(0), + turn_on_fn=lambda session: session.setWdrMode(1), + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: FoscamConfigEntry, @@ -22,63 +121,61 @@ async def async_setup_entry( """Set up foscam switch from a config entry.""" coordinator = config_entry.runtime_data - await coordinator.async_config_entry_first_refresh() - if coordinator.data["is_asleep"]["supported"]: - async_add_entities([FoscamSleepSwitch(coordinator, config_entry)]) + entities = [] + + product_info = coordinator.data.product_info + reserve3 = product_info.get("reserve3", "0") + + for description in SWITCH_DESCRIPTIONS: + if description.key == "is_asleep": + if not coordinator.data.is_asleep["supported"]: + continue + elif description.key == "is_open_hdr": + if ((1 << 8) & int(reserve3)) != 0 or ((1 << 7) & int(reserve3)) == 0: + continue + elif description.key == "is_open_wdr": + if ((1 << 8) & int(reserve3)) == 0: + continue + + entities.append(FoscamGenericSwitch(coordinator, description)) + async_add_entities(entities) -class FoscamSleepSwitch(FoscamEntity, SwitchEntity): - """An implementation for Sleep Switch.""" +class FoscamGenericSwitch(FoscamEntity, SwitchEntity): + """A generic switch class for Foscam entities.""" + + _attr_has_entity_name = True + entity_description: FoscamSwitchEntityDescription def __init__( self, coordinator: FoscamCoordinator, - config_entry: FoscamConfigEntry, + description: FoscamSwitchEntityDescription, ) -> None: - """Initialize a Foscam Sleep Switch.""" - super().__init__(coordinator, config_entry.entry_id) + """Initialize the generic switch.""" + entry_id = coordinator.config_entry.entry_id + super().__init__(coordinator, entry_id) - self._attr_unique_id = f"{config_entry.entry_id}_sleep_switch" - self._attr_translation_key = "sleep_switch" - self._attr_has_entity_name = True - - self.is_asleep = self.coordinator.data["is_asleep"]["status"] + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" @property - def is_on(self): - """Return true if camera is asleep.""" - return self.is_asleep + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.entity_description.native_value_fn(self.coordinator.data) async def async_turn_off(self, **kwargs: Any) -> None: - """Wake camera.""" - LOGGER.debug("Wake camera") - - ret, _ = await self.hass.async_add_executor_job( - self.coordinator.session.wake_up + """Turn off the entity.""" + self.hass.async_add_executor_job( + self.entity_description.turn_off_fn, self.coordinator.session ) - - if ret != 0: - raise HomeAssistantError(f"Error waking up: {ret}") - await self.coordinator.async_request_refresh() async def async_turn_on(self, **kwargs: Any) -> None: - """But camera is sleep.""" - LOGGER.debug("Sleep camera") - - ret, _ = await self.hass.async_add_executor_job(self.coordinator.session.sleep) - - if ret != 0: - raise HomeAssistantError(f"Error sleeping: {ret}") - + """Turn on the entity.""" + self.hass.async_add_executor_job( + self.entity_description.turn_on_fn, self.coordinator.session + ) await self.coordinator.async_request_refresh() - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - - self.is_asleep = self.coordinator.data["is_asleep"]["status"] - - self.async_write_ha_state() diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index b0242a1b054..968f3dc16a6 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities( ( - FreeboxAlarm(hass, router, node) + FreeboxAlarm(router, node) for node in router.home_devices.values() if node["category"] == FreeboxHomeCategory.ALARM ), @@ -49,11 +49,9 @@ class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): _attr_code_arm_required = False - def __init__( - self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] - ) -> None: + def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None: """Initialize an alarm.""" - super().__init__(hass, router, node) + super().__init__(router, node) # Commands self._command_trigger = self.get_command_id( diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 75b7dded36a..3b262309361 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -50,12 +50,12 @@ async def async_setup_entry( for node in router.home_devices.values(): if node["category"] == FreeboxHomeCategory.PIR: - binary_entities.append(FreeboxPirSensor(hass, router, node)) + binary_entities.append(FreeboxPirSensor(router, node)) elif node["category"] == FreeboxHomeCategory.DWS: - binary_entities.append(FreeboxDwsSensor(hass, router, node)) + binary_entities.append(FreeboxDwsSensor(router, node)) binary_entities.extend( - FreeboxCoverSensor(hass, router, node) + FreeboxCoverSensor(router, node) for endpoint in node["show_endpoints"] if ( endpoint["name"] == "cover" @@ -74,13 +74,12 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): def __init__( self, - hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any], sub_node: dict[str, Any] | None = None, ) -> None: """Initialize a Freebox binary sensor.""" - super().__init__(hass, router, node, sub_node) + super().__init__(router, node, sub_node) self._command_id = self.get_command_id( node["type"]["endpoints"], "signal", self._sensor_name ) @@ -123,9 +122,7 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor): _sensor_name = "cover" - def __init__( - self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] - ) -> None: + def __init__(self, router: FreeboxRouter, node: dict[str, Any]) -> None: """Initialize a cover for another device.""" cover_node = next( filter( @@ -134,7 +131,7 @@ class FreeboxCoverSensor(FreeboxHomeBinarySensor): ), None, ) - super().__init__(hass, router, node, cover_node) + super().__init__(router, node, cover_node) class FreeboxRaidDegradedSensor(BinarySensorEntity): diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index d997908dd06..f7e078f0736 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -74,7 +74,7 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): ) -> None: """Initialize a camera.""" - super().__init__(hass, router, node) + super().__init__(router, node) device_info = { CONF_NAME: node["label"].strip(), CONF_INPUT: node["props"]["Stream"], diff --git a/homeassistant/components/freebox/entity.py b/homeassistant/components/freebox/entity.py index 129186fd50b..17cd30f40ea 100644 --- a/homeassistant/components/freebox/entity.py +++ b/homeassistant/components/freebox/entity.py @@ -2,11 +2,9 @@ from __future__ import annotations -from collections.abc import Callable import logging from typing import Any -from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -22,13 +20,11 @@ class FreeboxHomeEntity(Entity): def __init__( self, - hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any], sub_node: dict[str, Any] | None = None, ) -> None: """Initialize a Freebox Home entity.""" - self._hass = hass self._router = router self._node = node self._sub_node = sub_node @@ -44,7 +40,6 @@ class FreeboxHomeEntity(Entity): self._available = True self._firmware = node["props"].get("FwVersion") self._manufacturer = "Freebox SAS" - self._remove_signal_update: Callable[[], None] | None = None self._model = CATEGORY_TO_MODEL.get(node["category"]) if self._model is None: @@ -61,10 +56,7 @@ class FreeboxHomeEntity(Entity): model=self._model, name=self._device_name, sw_version=self._firmware, - via_device=( - DOMAIN, - router.mac, - ), + via_device=(DOMAIN, router.mac), ) async def async_update_signal(self) -> None: @@ -116,23 +108,14 @@ class FreeboxHomeEntity(Entity): async def async_added_to_hass(self) -> None: """Register state update callback.""" - self.remove_signal_update( + self.async_on_remove( async_dispatcher_connect( - self._hass, + self.hass, self._router.signal_home_device_update, self.async_update_signal, ) ) - async def async_will_remove_from_hass(self) -> None: - """When entity will be removed from hass.""" - if self._remove_signal_update is not None: - self._remove_signal_update() - - def remove_signal_update(self, dispatcher: Callable[[], None]) -> None: - """Register state update callback.""" - self._remove_signal_update = dispatcher - def get_value(self, ep_type: str, name: str): """Get the value.""" node = next( diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index d6c45cd178b..b2eb329b545 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -115,8 +115,10 @@ class FreeboxRouter: self._api: Freepybox = api self.name: str = freebox_config["model_info"]["pretty_name"] + self.model_id: str = freebox_config["model_info"]["name"] self.mac: str = freebox_config["mac"] self._sw_v: str = freebox_config["firmware_version"] + self._hw_v: str | None = freebox_config.get("board_name") self._attrs: dict[str, Any] = {} self.supports_hosts = True @@ -282,7 +284,10 @@ class FreeboxRouter: identifiers={(DOMAIN, self.mac)}, manufacturer="Freebox SAS", name=self.name, + model=self.name, + model_id=self.model_id, sw_version=self._sw_v, + hw_version=self._hw_v, ) @property diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 45fe18db95a..53314549f57 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -68,7 +68,6 @@ async def async_setup_entry( ) -> None: """Set up the sensors.""" router = entry.runtime_data - entities: list[SensorEntity] = [] _LOGGER.debug( "%s - %s - %s temperature sensors", @@ -76,7 +75,7 @@ async def async_setup_entry( router.mac, len(router.sensors_temperature), ) - entities = [ + entities: list[SensorEntity] = [ FreeboxSensor( router, SensorEntityDescription( @@ -105,14 +104,16 @@ async def async_setup_entry( for description in DISK_PARTITION_SENSORS ) - for node in router.home_devices.values(): - for endpoint in node["show_endpoints"]: - if ( - endpoint["name"] == "battery" - and endpoint["ep_type"] == "signal" - and endpoint.get("value") is not None - ): - entities.append(FreeboxBatterySensor(hass, router, node, endpoint)) + entities.extend( + FreeboxBatterySensor(router, node, endpoint) + for node in router.home_devices.values() + for endpoint in node["show_endpoints"] + if ( + endpoint["name"] == "battery" + and endpoint["ep_type"] == "signal" + and endpoint.get("value") is not None + ) + ) if entities: async_add_entities(entities, True) diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index faf82b4b516..94f4f8ba0d8 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo if FRITZ_DATA_KEY not in hass.data: hass.data[FRITZ_DATA_KEY] = FritzData() - entry.async_on_unload(entry.add_update_listener(update_listener)) - # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -94,9 +92,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo hass.data.pop(FRITZ_DATA_KEY) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 2a4eb8c82b5..0bc772db5a4 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -15,8 +15,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ConnectionInfo, FritzConfigEntry +from .coordinator import FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription +from .models import ConnectionInfo _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 926e233d159..7fd158f3224 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -19,15 +19,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles -from .coordinator import ( - FRITZ_DATA_KEY, - AvmWrapper, - FritzConfigEntry, - FritzData, - FritzDevice, - _is_tracked, -) +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzDeviceBase +from .helpers import _is_tracked +from .models import FritzDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 2c22a35c4dd..270e9870c63 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,7 +17,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -409,7 +413,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): ) -class FritzBoxToolsOptionsFlowHandler(OptionsFlow): +class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index e22a66d254f..25687f0061a 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, ValuesView +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta from functools import partial @@ -34,7 +34,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from .const import ( @@ -48,6 +47,15 @@ from .const import ( FRITZ_EXCEPTIONS, MeshRoles, ) +from .helpers import _ha_is_stopping +from .models import ( + ConnectionInfo, + Device, + FritzDevice, + HostAttributes, + HostInfo, + Interface, +) _LOGGER = logging.getLogger(__name__) @@ -56,33 +64,13 @@ FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN) type FritzConfigEntry = ConfigEntry[AvmWrapper] -def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool: - """Check if device is already tracked.""" - return any(mac in tracked for tracked in current_devices) +@dataclass +class FritzData: + """Storage class for platform global data.""" - -def device_filter_out_from_trackers( - mac: str, - device: FritzDevice, - current_devices: ValuesView[set[str]], -) -> bool: - """Check if device should be filtered out from trackers.""" - reason: str | None = None - if device.ip_address == "": - reason = "Missing IP" - elif _is_tracked(mac, current_devices): - reason = "Already tracked" - - if reason: - _LOGGER.debug( - "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason - ) - return bool(reason) - - -def _ha_is_stopping(activity: str) -> None: - """Inform that HA is stopping.""" - _LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity) + tracked: dict[str, set[str]] = field(default_factory=dict) + profile_switches: dict[str, set[str]] = field(default_factory=dict) + wol_buttons: dict[str, set[str]] = field(default_factory=dict) class ClassSetupMissing(Exception): @@ -93,68 +81,6 @@ class ClassSetupMissing(Exception): super().__init__("Function called before Class setup") -@dataclass -class Device: - """FRITZ!Box device class.""" - - connected: bool - connected_to: str - connection_type: str - ip_address: str - name: str - ssid: str | None - wan_access: bool | None = None - - -class Interface(TypedDict): - """Interface details.""" - - device: str - mac: str - op_mode: str - ssid: str | None - type: str - - -HostAttributes = TypedDict( - "HostAttributes", - { - "Index": int, - "IPAddress": str, - "MACAddress": str, - "Active": bool, - "HostName": str, - "InterfaceType": str, - "X_AVM-DE_Port": int, - "X_AVM-DE_Speed": int, - "X_AVM-DE_UpdateAvailable": bool, - "X_AVM-DE_UpdateSuccessful": str, - "X_AVM-DE_InfoURL": str | None, - "X_AVM-DE_MACAddressList": str | None, - "X_AVM-DE_Model": str | None, - "X_AVM-DE_URL": str | None, - "X_AVM-DE_Guest": bool, - "X_AVM-DE_RequestClient": str, - "X_AVM-DE_VPN": bool, - "X_AVM-DE_WANAccess": str, - "X_AVM-DE_Disallow": bool, - "X_AVM-DE_IsMeshable": str, - "X_AVM-DE_Priority": str, - "X_AVM-DE_FriendlyName": str, - "X_AVM-DE_FriendlyNameIsWriteable": str, - }, -) - - -class HostInfo(TypedDict): - """FRITZ!Box host info class.""" - - mac: str - name: str - ip: str - status: bool - - class UpdateCoordinatorDataType(TypedDict): """Update coordinator data type.""" @@ -194,7 +120,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.fritz_guest_wifi: FritzGuestWLAN = None self.fritz_hosts: FritzHosts = None self.fritz_status: FritzStatus = None - self.hass = hass self.host = host self.mesh_role = MeshRoles.NONE self.mesh_wifi_uplink = False @@ -898,120 +823,3 @@ class AvmWrapper(FritzBoxTools): "X_AVM-DE_WakeOnLANByMACAddress", NewMACAddress=mac_address, ) - - -@dataclass -class FritzData: - """Storage class for platform global data.""" - - tracked: dict[str, set[str]] = field(default_factory=dict) - profile_switches: dict[str, set[str]] = field(default_factory=dict) - wol_buttons: dict[str, set[str]] = field(default_factory=dict) - - -class FritzDevice: - """Representation of a device connected to the FRITZ!Box.""" - - def __init__(self, mac: str, name: str) -> None: - """Initialize device info.""" - self._connected = False - self._connected_to: str | None = None - self._connection_type: str | None = None - self._ip_address: str | None = None - self._last_activity: datetime | None = None - self._mac = mac - self._name = name - self._ssid: str | None = None - self._wan_access: bool | None = False - - def update(self, dev_info: Device, consider_home: float) -> None: - """Update device info.""" - utc_point_in_time = dt_util.utcnow() - - if self._last_activity: - consider_home_evaluated = ( - utc_point_in_time - self._last_activity - ).total_seconds() < consider_home - else: - consider_home_evaluated = dev_info.connected - - if not self._name: - self._name = dev_info.name or self._mac.replace(":", "_") - - self._connected = dev_info.connected or consider_home_evaluated - - if dev_info.connected: - self._last_activity = utc_point_in_time - - self._connected_to = dev_info.connected_to - self._connection_type = dev_info.connection_type - self._ip_address = dev_info.ip_address - self._ssid = dev_info.ssid - self._wan_access = dev_info.wan_access - - @property - def connected_to(self) -> str | None: - """Return connected status.""" - return self._connected_to - - @property - def connection_type(self) -> str | None: - """Return connected status.""" - return self._connection_type - - @property - def is_connected(self) -> bool: - """Return connected status.""" - return self._connected - - @property - def mac_address(self) -> str: - """Get MAC address.""" - return self._mac - - @property - def hostname(self) -> str: - """Get Name.""" - return self._name - - @property - def ip_address(self) -> str | None: - """Get IP address.""" - return self._ip_address - - @property - def last_activity(self) -> datetime | None: - """Return device last activity.""" - return self._last_activity - - @property - def ssid(self) -> str | None: - """Return device connected SSID.""" - return self._ssid - - @property - def wan_access(self) -> bool | None: - """Return device wan access.""" - return self._wan_access - - -class SwitchInfo(TypedDict): - """FRITZ!Box switch info class.""" - - description: str - friendly_name: str - icon: str - type: str - callback_update: Callable - callback_switch: Callable - init_state: bool - - -@dataclass -class ConnectionInfo: - """Fritz sensor connection information class.""" - - connection: str - mesh_role: MeshRoles - wan_enabled: bool - ipv6_active: bool diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 618214a1c55..a658f5d19cb 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -10,15 +10,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ( - FRITZ_DATA_KEY, - AvmWrapper, - FritzConfigEntry, - FritzData, - FritzDevice, - device_filter_out_from_trackers, -) +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzDeviceBase +from .helpers import device_filter_out_from_trackers +from .models import FritzDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index e8b5c49fd43..49dc73bba26 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -14,7 +14,8 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_DEVICE_NAME, DOMAIN -from .coordinator import AvmWrapper, FritzDevice +from .coordinator import AvmWrapper +from .models import FritzDevice class FritzDeviceBase(CoordinatorEntity[AvmWrapper]): diff --git a/homeassistant/components/fritz/helpers.py b/homeassistant/components/fritz/helpers.py new file mode 100644 index 00000000000..af75b97e59a --- /dev/null +++ b/homeassistant/components/fritz/helpers.py @@ -0,0 +1,39 @@ +"""Helpers for AVM FRITZ!Box.""" + +from __future__ import annotations + +from collections.abc import ValuesView +import logging + +from .models import FritzDevice + +_LOGGER = logging.getLogger(__name__) + + +def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool: + """Check if device is already tracked.""" + return any(mac in tracked for tracked in current_devices) + + +def device_filter_out_from_trackers( + mac: str, + device: FritzDevice, + current_devices: ValuesView[set[str]], +) -> bool: + """Check if device should be filtered out from trackers.""" + reason: str | None = None + if device.ip_address == "": + reason = "Missing IP" + elif _is_tracked(mac, current_devices): + reason = "Already tracked" + + if reason: + _LOGGER.debug( + "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason + ) + return bool(reason) + + +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/models.py b/homeassistant/components/fritz/models.py new file mode 100644 index 00000000000..f66c1d338b9 --- /dev/null +++ b/homeassistant/components/fritz/models.py @@ -0,0 +1,182 @@ +"""Models for AVM FRITZ!Box.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import TypedDict + +from homeassistant.util import dt as dt_util + +from .const import MeshRoles + + +@dataclass +class Device: + """FRITZ!Box device class.""" + + connected: bool + connected_to: str + connection_type: str + ip_address: str + name: str + ssid: str | None + wan_access: bool | None = None + + +class Interface(TypedDict): + """Interface details.""" + + device: str + mac: str + op_mode: str + ssid: str | None + type: str + + +HostAttributes = TypedDict( + "HostAttributes", + { + "Index": int, + "IPAddress": str, + "MACAddress": str, + "Active": bool, + "HostName": str, + "InterfaceType": str, + "X_AVM-DE_Port": int, + "X_AVM-DE_Speed": int, + "X_AVM-DE_UpdateAvailable": bool, + "X_AVM-DE_UpdateSuccessful": str, + "X_AVM-DE_InfoURL": str | None, + "X_AVM-DE_MACAddressList": str | None, + "X_AVM-DE_Model": str | None, + "X_AVM-DE_URL": str | None, + "X_AVM-DE_Guest": bool, + "X_AVM-DE_RequestClient": str, + "X_AVM-DE_VPN": bool, + "X_AVM-DE_WANAccess": str, + "X_AVM-DE_Disallow": bool, + "X_AVM-DE_IsMeshable": str, + "X_AVM-DE_Priority": str, + "X_AVM-DE_FriendlyName": str, + "X_AVM-DE_FriendlyNameIsWriteable": str, + }, +) + + +class HostInfo(TypedDict): + """FRITZ!Box host info class.""" + + mac: str + name: str + ip: str + status: bool + + +class FritzDevice: + """Representation of a device connected to the FRITZ!Box.""" + + def __init__(self, mac: str, name: str) -> None: + """Initialize device info.""" + self._connected = False + self._connected_to: str | None = None + self._connection_type: str | None = None + self._ip_address: str | None = None + self._last_activity: datetime | None = None + self._mac = mac + self._name = name + self._ssid: str | None = None + self._wan_access: bool | None = False + + def update(self, dev_info: Device, consider_home: float) -> None: + """Update device info.""" + utc_point_in_time = dt_util.utcnow() + + if self._last_activity: + consider_home_evaluated = ( + utc_point_in_time - self._last_activity + ).total_seconds() < consider_home + else: + consider_home_evaluated = dev_info.connected + + if not self._name: + self._name = dev_info.name or self._mac.replace(":", "_") + + self._connected = dev_info.connected or consider_home_evaluated + + if dev_info.connected: + self._last_activity = utc_point_in_time + + self._connected_to = dev_info.connected_to + self._connection_type = dev_info.connection_type + self._ip_address = dev_info.ip_address + self._ssid = dev_info.ssid + self._wan_access = dev_info.wan_access + + @property + def connected_to(self) -> str | None: + """Return connected status.""" + return self._connected_to + + @property + def connection_type(self) -> str | None: + """Return connected status.""" + return self._connection_type + + @property + def is_connected(self) -> bool: + """Return connected status.""" + return self._connected + + @property + def mac_address(self) -> str: + """Get MAC address.""" + return self._mac + + @property + def hostname(self) -> str: + """Get Name.""" + return self._name + + @property + def ip_address(self) -> str | None: + """Get IP address.""" + return self._ip_address + + @property + def last_activity(self) -> datetime | None: + """Return device last activity.""" + return self._last_activity + + @property + def ssid(self) -> str | None: + """Return device connected SSID.""" + return self._ssid + + @property + def wan_access(self) -> bool | None: + """Return device wan access.""" + return self._wan_access + + +class SwitchInfo(TypedDict): + """FRITZ!Box switch info class.""" + + description: str + friendly_name: str + icon: str + type: str + callback_update: Callable + callback_switch: Callable + init_state: bool + + +@dataclass +class ConnectionInfo: + """Fritz sensor connection information class.""" + + connection: str + mesh_role: MeshRoles + wan_enabled: bool + ipv6_active: bool diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 65a776b9ad5..e2df5dc6e8b 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -27,8 +27,9 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from .const import DSL_CONNECTION, UPTIME_DEVIATION -from .coordinator import ConnectionInfo, FritzConfigEntry +from .coordinator import FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription +from .models import ConnectionInfo _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index ee23a8cfbef..45d66e9621b 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -214,7 +214,7 @@ "message": "Unable to establish a connection" }, "update_failed": { - "message": "Error while uptaing the data: {error}" + "message": "Error while updating the data: {error}" } } } diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index a033e45fcec..f1c34682cff 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -25,16 +25,10 @@ from .const import ( WIFI_STANDARD, MeshRoles, ) -from .coordinator import ( - FRITZ_DATA_KEY, - AvmWrapper, - FritzConfigEntry, - FritzData, - FritzDevice, - SwitchInfo, - device_filter_out_from_trackers, -) +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzBoxBaseEntity, FritzDeviceBase +from .helpers import device_filter_out_from_trackers +from .models import FritzDevice, SwitchInfo _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 8a37ebf63e4..a95af62da6c 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -171,14 +171,19 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat for device in new_data.devices.values(): # create device registry entry for new main devices - if ( - device.ain not in self.data.devices - and device.device_and_unit_id[1] is None + if device.ain not in self.data.devices and ( + device.device_and_unit_id[1] is None + or ( + # workaround for sub units without a main device, e.g. Energy 250 + # https://github.com/home-assistant/core/issues/145204 + device.device_and_unit_id[1] == "1" + and device.device_and_unit_id[0] not in new_data.devices + ) ): dr.async_get(self.hass).async_get_or_create( config_entry_id=self.config_entry.entry_id, name=device.name, - identifiers={(DOMAIN, device.ain)}, + identifiers={(DOMAIN, device.device_and_unit_id[0])}, manufacturer=device.manufacturer, model=device.productname, sw_version=device.fw_version, diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index b1b5db48216..ea4bf46f09c 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -48,7 +48,6 @@ async def async_setup_entry( raise ConfigEntryNotReady from ex config_entry.runtime_data = fritzbox_phonebook - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -59,10 +58,3 @@ async def async_unload_entry( ) -> bool: """Unloading the fritzbox_callmonitor platforms.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry -) -> None: - """Update listener to reload after option has changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 8435eff3e18..25e25336d57 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback @@ -263,7 +263,7 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlow): +class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlowWithReload): """Handle a fritzbox_callmonitor options flow.""" @classmethod diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 8a3d1ebf04c..cfbdfbcb424 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -106,6 +106,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_logger_{self.host}", + config_entry=self.config_entry, ) await self.logger_coordinator.async_config_entry_first_refresh() @@ -120,6 +121,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_meters_{self.host}", + config_entry=self.config_entry, ) ) @@ -129,6 +131,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_ohmpilot_{self.host}", + config_entry=self.config_entry, ) ) @@ -138,6 +141,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_power_flow_{self.host}", + config_entry=self.config_entry, ) ) @@ -147,6 +151,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_storages_{self.host}", + config_entry=self.config_entry, ) ) @@ -206,6 +211,7 @@ class FroniusSolarNet: logger=_LOGGER, name=_inverter_name, inverter_info=_inverter_info, + config_entry=self.config_entry, ) if self.config_entry.state == ConfigEntryState.LOADED: await _coordinator.async_refresh() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9694c299b23..ff50567257a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted @@ -49,7 +50,7 @@ CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5" CONF_FRONTEND_REPO = "development_repo" CONF_JS_VERSION = "javascript_version" -DEFAULT_THEME_COLOR = "#03A9F4" +DEFAULT_THEME_COLOR = "#2980b9" DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels") @@ -543,6 +544,12 @@ async def _async_setup_themes( """Reload themes.""" config = await async_hass_config_yaml(hass) new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) + + try: + THEME_SCHEMA(new_themes) + except vol.Invalid as err: + raise HomeAssistantError(f"Failed to reload themes: {err}") from err + hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d996963cb9c..3488ddc5e5c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.4"] + "requirements": ["home-assistant-frontend==20250811.0"] } diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index bf1df07823c..85ef119a583 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( FAN_HIGH, FAN_LOW, FAN_MEDIUM, + FAN_OFF, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -31,6 +32,7 @@ from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity HA_TO_FUJI_FAN = { + FAN_OFF: FanSpeed.QUIET, FAN_LOW: FanSpeed.LOW, FAN_MEDIUM: FanSpeed.MEDIUM, FAN_HIGH: FanSpeed.HIGH, diff --git a/homeassistant/components/fyta/const.py b/homeassistant/components/fyta/const.py index bf4636a713a..9e1898f5ae6 100644 --- a/homeassistant/components/fyta/const.py +++ b/homeassistant/components/fyta/const.py @@ -2,3 +2,8 @@ DOMAIN = "fyta" CONF_EXPIRATION = "expiration" + +CONF_MAX_ACCEPTABLE = "max_acceptable" +CONF_MAX_GOOD = "max_good" +CONF_MIN_ACCEPTABLE = "min_acceptable" +CONF_MIN_GOOD = "min_good" diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 622945ae102..d16a3eccfff 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -25,6 +25,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from .const import ( + CONF_MAX_ACCEPTABLE, + CONF_MAX_GOOD, + CONF_MIN_ACCEPTABLE, + CONF_MIN_GOOD, +) from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity @@ -36,6 +42,13 @@ class FytaSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Plant], StateType | datetime] +@dataclass(frozen=True, kw_only=True) +class FytaMeasurementSensorEntityDescription(FytaSensorEntityDescription): + """Describes Fyta sensor entity.""" + + attribute_fn: Callable[[Plant], dict[str, float | None]] + + PLANT_STATUS_LIST: list[str] = ["deleted", "doing_great", "need_attention", "no_sensor"] PLANT_MEASUREMENT_STATUS_LIST: list[str] = [ "no_data", @@ -95,35 +108,6 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ options=PLANT_MEASUREMENT_STATUS_LIST, value_fn=lambda plant: plant.salinity_status.name.lower(), ), - FytaSensorEntityDescription( - key="temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.temperature, - ), - FytaSensorEntityDescription( - key="light", - translation_key="light", - native_unit_of_measurement="μmol/s⋅m²", - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.light, - ), - FytaSensorEntityDescription( - key="moisture", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.MOISTURE, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.moisture, - ), - FytaSensorEntityDescription( - key="salinity", - translation_key="salinity", - native_unit_of_measurement=UnitOfConductivity.MILLISIEMENS_PER_CM, - device_class=SensorDeviceClass.CONDUCTIVITY, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda plant: plant.salinity, - ), FytaSensorEntityDescription( key="ph", device_class=SensorDeviceClass.PH, @@ -152,6 +136,62 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ ), ] +MEASUREMENT_SENSORS: Final[list[FytaMeasurementSensorEntityDescription]] = [ + FytaMeasurementSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.temperature_max_acceptable, + CONF_MAX_GOOD: plant.temperature_max_good, + CONF_MIN_ACCEPTABLE: plant.temperature_min_acceptable, + CONF_MIN_GOOD: plant.temperature_min_good, + }, + value_fn=lambda plant: plant.temperature, + ), + FytaMeasurementSensorEntityDescription( + key="light", + translation_key="light", + native_unit_of_measurement="μmol/s⋅m²", + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.light_max_acceptable, + CONF_MAX_GOOD: plant.light_max_good, + CONF_MIN_ACCEPTABLE: plant.light_min_acceptable, + CONF_MIN_GOOD: plant.light_min_good, + }, + value_fn=lambda plant: plant.light, + ), + FytaMeasurementSensorEntityDescription( + key="moisture", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.moisture_max_acceptable, + CONF_MAX_GOOD: plant.moisture_max_good, + CONF_MIN_ACCEPTABLE: plant.moisture_min_acceptable, + CONF_MIN_GOOD: plant.moisture_min_good, + }, + value_fn=lambda plant: plant.moisture, + ), + FytaMeasurementSensorEntityDescription( + key="salinity", + translation_key="salinity", + native_unit_of_measurement=UnitOfConductivity.MILLISIEMENS_PER_CM, + device_class=SensorDeviceClass.CONDUCTIVITY, + state_class=SensorStateClass.MEASUREMENT, + attribute_fn=lambda plant: { + CONF_MAX_ACCEPTABLE: plant.salinity_max_acceptable, + CONF_MAX_GOOD: plant.salinity_max_good, + CONF_MIN_ACCEPTABLE: plant.salinity_min_acceptable, + CONF_MIN_GOOD: plant.salinity_min_good, + }, + value_fn=lambda plant: plant.salinity, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -168,14 +208,28 @@ async def async_setup_entry( if sensor.key in dir(coordinator.data.get(plant_id)) ] + plant_entities.extend( + FytaPlantMeasurementSensor(coordinator, entry, sensor, plant_id) + for plant_id in coordinator.fyta.plant_list + for sensor in MEASUREMENT_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + async_add_entities(plant_entities) def _async_add_new_device(plant_id: int) -> None: - async_add_entities( + plant_entities = [ FytaPlantSensor(coordinator, entry, sensor, plant_id) for sensor in SENSORS if sensor.key in dir(coordinator.data.get(plant_id)) + ] + + plant_entities.extend( + FytaPlantMeasurementSensor(coordinator, entry, sensor, plant_id) + for sensor in MEASUREMENT_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) ) + async_add_entities(plant_entities) coordinator.new_device_callbacks.append(_async_add_new_device) @@ -190,3 +244,15 @@ class FytaPlantSensor(FytaPlantEntity, SensorEntity): """Return the state for this sensor.""" return self.entity_description.value_fn(self.plant) + + +class FytaPlantMeasurementSensor(FytaPlantSensor): + """Represents a Fyta measurement sensor.""" + + entity_description: FytaMeasurementSensorEntityDescription + + @property + def extra_state_attributes(self) -> dict[str, float | None]: + """Return the device state attributes.""" + + return self.entity_description.attribute_fn(self.plant) diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 67bb991a437..b0c14e0d4c1 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -138,10 +138,64 @@ } }, "light": { - "name": "Light" + "name": "Light", + "state_attributes": { + "max_acceptable": { "name": "Maximum acceptable" }, + "max_good": { "name": "Maximum good" }, + "min_acceptable": { "name": "Minimum acceptable" }, + "min_good": { "name": "Minimum good" } + } + }, + "moisture": { + "name": "[%key:component::sensor::entity_component::moisture::name%]", + "state_attributes": { + "max_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_acceptable::name%]" + }, + "max_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_good::name%]" + }, + "min_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_acceptable::name%]" + }, + "min_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_good::name%]" + } + } }, "salinity": { - "name": "Salinity" + "name": "Salinity", + "state_attributes": { + "max_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_acceptable::name%]" + }, + "max_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_good::name%]" + }, + "min_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_acceptable::name%]" + }, + "min_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_good::name%]" + } + } + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]", + "state_attributes": { + "max_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_acceptable::name%]" + }, + "max_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::max_good::name%]" + }, + "min_acceptable": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_acceptable::name%]" + }, + "min_good": { + "name": "[%key:component::fyta::entity::sensor::light::state_attributes::min_good::name%]" + } + } }, "last_fertilised": { "name": "Last fertilized" diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 7652b4b6f3b..e74deac25c4 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==6.1.1"] + "requirements": ["odp-amsterdam==6.1.2"] } diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 34f72bf0a5a..4a21bb3d3e4 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -13,6 +13,7 @@ from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import dt as dt_util @@ -74,6 +75,7 @@ async def async_setup_entry( device = DeviceInfo( identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, name=name, sw_version=sw_version, manufacturer=manufacturer, diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 41b4f1e79ba..342061c18d1 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -13,6 +13,7 @@ from gardena_bluetooth.parse import ( ) from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, @@ -54,6 +55,7 @@ DESCRIPTIONS = ( native_step=60, entity_category=EntityCategory.CONFIG, char=Valve.manual_watering_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Valve.remaining_open_time.uuid, @@ -64,6 +66,7 @@ DESCRIPTIONS = ( native_step=60.0, entity_category=EntityCategory.DIAGNOSTIC, char=Valve.remaining_open_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.rain_pause.uuid, @@ -75,6 +78,7 @@ DESCRIPTIONS = ( native_step=6 * 60.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.rain_pause, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.seasonal_adjust.uuid, @@ -86,6 +90,7 @@ DESCRIPTIONS = ( native_step=1.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.seasonal_adjust, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Sensor.threshold.uuid, @@ -153,6 +158,7 @@ class GardenaBluetoothRemainingOpenSetNumber(GardenaBluetoothEntity, NumberEntit _attr_native_min_value = 0.0 _attr_native_max_value = 24 * 60 _attr_native_step = 1.0 + _attr_device_class = NumberDeviceClass.DURATION def __init__( self, diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 4138c7c4472..247a85f93f1 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -6,7 +6,11 @@ from typing import Any from gardena_bluetooth.const import Valve -from homeassistant.components.valve import ValveEntity, ValveEntityFeature +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +41,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): _attr_is_closed: bool | None = None _attr_reports_position = False _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_device_class = ValveDeviceClass.WATER characteristics = { Valve.state.uuid, diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index b20793fe060..0621ca369db 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping import contextlib -from datetime import datetime, timedelta +from datetime import datetime from errno import EHOSTUNREACH, EIO import io import logging @@ -52,9 +52,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper -from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.setup import async_prepare_setup_platform from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -569,18 +568,9 @@ async def ws_start_preview( ) user_input = flow.preview_image_settings - # Create an EntityPlatform, needed for name translations - platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) - entity_platform = EntityPlatform( - hass=hass, - logger=_LOGGER, - domain=CAMERA_DOMAIN, - platform_name=DOMAIN, - platform=platform, - scan_interval=timedelta(seconds=3600), - entity_namespace=None, - ) - await entity_platform.async_load_translations() + # Create PlatformData, needed for name translations + platform_data = PlatformData(hass=hass, domain=CAMERA_DOMAIN, platform_name=DOMAIN) + await platform_data.async_load_translations() ha_still_url = None ha_stream_url = None diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index b5e25c08851..bef0d81d77b 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==13.1.0", "Pillow==11.2.1"] + "requirements": ["av==13.1.0", "Pillow==11.3.0"] } diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index a12994c1a75..d907f863988 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -1,5 +1,7 @@ """The generic_hygrostat component.""" +import logging + import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass @@ -16,7 +18,10 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType DOMAIN = "generic_hygrostat" @@ -70,6 +75,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Generic Hygrostat component.""" @@ -89,6 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -101,23 +109,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_HUMIDIFIER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the humidifer, # but not the humidity sensor because the generic_hygrostat adds itself to the # humidifier's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HUMIDIFIER] ), source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER], - source_entity_removed=source_entity_removed, ) ) @@ -148,6 +152,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_hygrostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HUMIDIFIER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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 + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py index 7c35b0e9317..449fa49b713 100644 --- a/homeassistant/components/generic_hygrostat/config_flow.py +++ b/homeassistant/components/generic_hygrostat/config_flow.py @@ -92,6 +92,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 6e699745279..7746346d010 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -42,7 +42,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -145,22 +145,22 @@ async def _async_setup_config( [ GenericHygrostat( hass, - name, - switch_entity_id, - sensor_entity_id, - min_humidity, - max_humidity, - target_humidity, - device_class, - min_cycle_duration, - dry_tolerance, - wet_tolerance, - keep_alive, - initial_state, - away_humidity, - away_fixed, - sensor_stale_duration, - unique_id, + name=name, + switch_entity_id=switch_entity_id, + sensor_entity_id=sensor_entity_id, + min_humidity=min_humidity, + max_humidity=max_humidity, + target_humidity=target_humidity, + device_class=device_class, + min_cycle_duration=min_cycle_duration, + dry_tolerance=dry_tolerance, + wet_tolerance=wet_tolerance, + keep_alive=keep_alive, + initial_state=initial_state, + away_humidity=away_humidity, + away_fixed=away_fixed, + sensor_stale_duration=sensor_stale_duration, + unique_id=unique_id, ) ] ) @@ -174,6 +174,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, switch_entity_id: str, sensor_entity_id: str, @@ -195,7 +196,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._name = name self._switch_entity_id = switch_entity_id self._sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, switch_entity_id, ) diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index 3e2af8598de..98cd9a02baa 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -1,5 +1,7 @@ """The generic_thermostat component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import entity_registry as er @@ -8,14 +10,20 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -28,23 +36,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_HEATER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the heater, but # not the temperature sensor because the generic_hygrostat adds itself to the # heater's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HEATER] ), source_entity_id_or_uuid=entry.options[CONF_HEATER], - source_entity_removed=source_entity_removed, ) ) @@ -75,6 +79,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_thermostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HEATER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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 + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 185040f02c9..76fcc4acdde 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -48,7 +48,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -182,23 +182,23 @@ async def _async_setup_config( [ GenericThermostat( hass, - name, - heater_entity_id, - sensor_entity_id, - min_temp, - max_temp, - target_temp, - ac_mode, - min_cycle_duration, - cold_tolerance, - hot_tolerance, - keep_alive, - initial_hvac_mode, - presets, - precision, - target_temperature_step, - unit, - unique_id, + name=name, + heater_entity_id=heater_entity_id, + sensor_entity_id=sensor_entity_id, + min_temp=min_temp, + max_temp=max_temp, + target_temp=target_temp, + ac_mode=ac_mode, + min_cycle_duration=min_cycle_duration, + cold_tolerance=cold_tolerance, + hot_tolerance=hot_tolerance, + keep_alive=keep_alive, + initial_hvac_mode=initial_hvac_mode, + presets=presets, + precision=precision, + target_temperature_step=target_temperature_step, + unit=unit, + unique_id=unique_id, ) ] ) @@ -212,6 +212,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, heater_entity_id: str, sensor_entity_id: str, @@ -234,7 +235,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._attr_name = name self.heater_entity_id = heater_entity_id self.sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, heater_entity_id, ) diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 1fbeaefde6b..b69106597d1 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -100,6 +100,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 5f0d6e92ee1..ab5bde3682e 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -7,6 +7,7 @@ from typing import Final import voluptuous as vol +from homeassistant.components.zone import condition as zone_condition from homeassistant.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE from homeassistant.core import ( CALLBACK_TYPE, @@ -17,7 +18,7 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import entity_domain from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo @@ -79,9 +80,11 @@ async def async_attach_trigger( return from_match = ( - condition.zone(hass, zone_state, from_state) if from_state else False + zone_condition.zone(hass, zone_state, from_state) if from_state else False + ) + to_match = ( + zone_condition.zone(hass, zone_state, to_state) if to_state else False ) - to_match = condition.zone(hass, zone_state, to_state) if to_state else False if (trigger_event == EVENT_ENTER and not from_match and to_match) or ( trigger_event == EVENT_LEAVE and from_match and not to_match diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 2294e89c961..2d21b0b8d9e 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -19,6 +19,8 @@ API_TIMEOUT: Final = 30 ATTR_C6H6: Final = "c6h6" ATTR_CO: Final = "co" +ATTR_NO: Final = "no" +ATTR_NOX: Final = "nox" ATTR_NO2: Final = "no2" ATTR_O3: Final = "o3" ATTR_PM10: Final = "pm10" diff --git a/homeassistant/components/gios/icons.json b/homeassistant/components/gios/icons.json index e1d848e276b..2623ee1549d 100644 --- a/homeassistant/components/gios/icons.json +++ b/homeassistant/components/gios/icons.json @@ -13,6 +13,9 @@ "no2_index": { "default": "mdi:molecule" }, + "nox": { + "default": "mdi:molecule" + }, "o3_index": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 8deb2eee414..8c6765ece89 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.0.0"] + "requirements": ["gios==6.1.2"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 67997a01dc6..b8583adfcf1 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -27,7 +27,9 @@ from .const import ( ATTR_AQI, ATTR_C6H6, ATTR_CO, + ATTR_NO, ATTR_NO2, + ATTR_NOX, ATTR_O3, ATTR_PM10, ATTR_PM25, @@ -74,6 +76,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, translation_key="co", ), + GiosSensorEntityDescription( + key=ATTR_NO, + value=lambda sensors: sensors.no.value if sensors.no else None, + suggested_display_precision=0, + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), GiosSensorEntityDescription( key=ATTR_NO2, value=lambda sensors: sensors.no2.value if sensors.no2 else None, @@ -90,6 +100,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="no2_index", ), + GiosSensorEntityDescription( + key=ATTR_NOX, + translation_key=ATTR_NOX, + value=lambda sensors: sensors.nox.value if sensors.nox else None, + suggested_display_precision=0, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), GiosSensorEntityDescription( key=ATTR_O3, value=lambda sensors: sensors.o3.value if sensors.o3 else None, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index eca23159a13..d19edd63717 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -77,6 +77,9 @@ } } }, + "nox": { + "name": "Nitrogen oxides" + }, "o3_index": { "name": "Ozone index", "state": { diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index dea2acf4f1b..df50039b03f 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo async_cleanup_device_registry(hass=hass, entry=entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True @@ -87,8 +86,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b coordinator.unsubscribe() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 17338119b9f..a2a7e56830f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback @@ -214,7 +214,7 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for GitHub.""" async def async_step_init( diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 28cf40aae6e..5df8fe1b2e4 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -29,7 +29,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, entry: GlancesConfigEntry, api: Glances ) -> None: """Initialize the Glances data.""" - self.hass = hass self.host: str = entry.data[CONF_HOST] self.api = api super().__init__( diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 4e15b93330c..aeedb847090 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -306,6 +306,11 @@ class WebRTCProvider(CameraWebRTCProvider): await self.teardown() raise HomeAssistantError("Camera has no stream source") + if camera.platform.platform_name == "generic": + # This is a workaround to use ffmpeg for generic cameras + # A proper fix will be added in the future together with supporting multiple streams per camera + stream_source = "ffmpeg:" + stream_source + if not self.async_is_supported(stream_source): await self.teardown() raise HomeAssistantError("Stream source is not supported by go2rtc") @@ -323,7 +328,6 @@ class WebRTCProvider(CameraWebRTCProvider): # Connection problems to the camera will be logged by the first stream # Therefore setting it to debug will not hide any important logs f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index c6d85bd4c10..8c0477c8f6a 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -12,7 +12,7 @@ } }, "confirm_discovery": { - "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual." + "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new IP address. Refer to your router's user manual." } }, "error": { diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index b6637bc8b50..e191e1b775f 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -1,6 +1,7 @@ """The Goodwe inverter component.""" from goodwe import InverterError, connect +from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -20,11 +21,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo try: inverter = await connect( host=host, + port=GOODWE_UDP_PORT, family=model_family, retries=10, ) - except InverterError as err: - raise ConfigEntryNotReady from err + except InverterError as err_udp: + # First try with UDP failed, trying with the TCP port + try: + inverter = await connect( + host=host, + port=GOODWE_TCP_PORT, + family=model_family, + retries=10, + ) + except InverterError: + # Both ports are unavailable + raise ConfigEntryNotReady from err_udp device_info = DeviceInfo( configuration_url="https://www.semsportal.com", diff --git a/homeassistant/components/goodwe/config_flow.py b/homeassistant/components/goodwe/config_flow.py index 354877e782f..72d27e02b2e 100644 --- a/homeassistant/components/goodwe/config_flow.py +++ b/homeassistant/components/goodwe/config_flow.py @@ -6,6 +6,7 @@ import logging from typing import Any from goodwe import InverterError, connect +from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -27,6 +28,18 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + async def _handle_successful_connection(self, inverter, host): + await self.async_set_unique_id(inverter.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_NAME, + data={ + CONF_HOST: host, + CONF_MODEL_FAMILY: type(inverter).__name__, + }, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -34,22 +47,19 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: host = user_input[CONF_HOST] - try: - inverter = await connect(host=host, retries=10) + inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10) except InverterError: - errors[CONF_HOST] = "connection_error" + try: + inverter = await connect( + host=host, port=GOODWE_TCP_PORT, retries=10 + ) + except InverterError: + errors[CONF_HOST] = "connection_error" + else: + return await self._handle_successful_connection(inverter, host) else: - await self.async_set_unique_id(inverter.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=DEFAULT_NAME, - data={ - CONF_HOST: host, - CONF_MODEL_FAMILY: type(inverter).__name__, - }, - ) + return await self._handle_successful_connection(inverter, host) return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, errors=errors diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 41e0ed91f6a..2f04ee3982f 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.3.6"] + "requirements": ["goodwe==0.4.8"] } diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index c26e8135b3f..7d58b099ddc 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -54,17 +54,24 @@ async def async_setup_entry( # Inverter model does not support this setting _LOGGER.debug("Could not read inverter operation mode") else: - async_add_entities( - [ - InverterOperationModeEntity( - device_info, - OPERATION_MODE, - inverter, - [v for k, v in _MODE_TO_OPTION.items() if k in supported_modes], - _MODE_TO_OPTION[active_mode], - ) - ] - ) + active_mode_option = _MODE_TO_OPTION.get(active_mode) + if active_mode_option is not None: + async_add_entities( + [ + InverterOperationModeEntity( + device_info, + OPERATION_MODE, + inverter, + [v for k, v in _MODE_TO_OPTION.items() if k in supported_modes], + active_mode_option, + ) + ] + ) + else: + _LOGGER.warning( + "Active mode %s not found in Goodwe Inverter Operation Mode Entity. Skipping entity creation", + active_mode, + ) class InverterOperationModeEntity(SelectEntity): diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 6fef46395e8..d6d740bd0aa 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -230,7 +230,7 @@ async def async_setup_entry( calendar_info = calendars[calendar_id] else: calendar_info = get_calendar_info( - hass, calendar_item.dict(exclude_unset=True) + hass, calendar_item.model_dump(exclude_unset=True) ) new_calendars.append(calendar_info) @@ -467,7 +467,7 @@ class GoogleCalendarEntity( else: start = DateOrDatetime(date=dtstart) end = DateOrDatetime(date=dtend) - event = Event.parse_obj( + event = Event.model_validate( { EVENT_SUMMARY: kwargs[EVENT_SUMMARY], "start": start, @@ -538,7 +538,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> if EVENT_IN in call.data: if EVENT_IN_DAYS in call.data[EVENT_IN]: - now = datetime.now() + now = datetime.now().date() start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) end_in = start_in + timedelta(days=1) @@ -547,7 +547,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> end = DateOrDatetime(date=end_in) elif EVENT_IN_WEEKS in call.data[EVENT_IN]: - now = datetime.now() + now = datetime.now().date() start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) end_in = start_in + timedelta(days=1) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 1acfa3a2ad1..b15372b1555 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"] } diff --git a/homeassistant/components/google_assistant_sdk/application_credentials.py b/homeassistant/components/google_assistant_sdk/application_credentials.py index 8fa99157479..8f5b00edc7c 100644 --- a/homeassistant/components/google_assistant_sdk/application_credentials.py +++ b/homeassistant/components/google_assistant_sdk/application_credentials.py @@ -2,6 +2,10 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AUTH_CALLBACK_PATH, + MY_AUTH_CALLBACK_PATH, +) async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -14,12 +18,14 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: """Return description placeholders for the credentials dialog.""" + if "my" in hass.config.components: + redirect_url = MY_AUTH_CALLBACK_PATH + else: + ha_host = hass.config.external_url or "https://YOUR_DOMAIN:PORT" + redirect_url = f"{ha_host}{AUTH_CALLBACK_PATH}" return { - "oauth_consent_url": ( - "https://console.cloud.google.com/apis/credentials/consent" - ), - "more_info_url": ( - "https://www.home-assistant.io/integrations/google_assistant_sdk/" - ), + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": redirect_url, } diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index b319e1e432c..c40c848ff3f 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -80,10 +80,10 @@ async def async_send_text_commands( credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass)) + command_response_list = [] with TextAssistant( credentials, language_code, audio_out=bool(media_players) ) as assistant: - command_response_list = [] for command in commands: try: resp = await hass.async_add_executor_job(assistant.assist, command) @@ -117,7 +117,7 @@ async def async_send_text_commands( blocking=True, ) command_response_list.append(CommandResponse(text_response)) - return command_response_list + return command_response_list def default_language_code(hass: HomeAssistant) -> str: diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 70e93f39f42..5a6a42c394c 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["gassist-text==0.0.12"], + "requirements": ["gassist-text==0.0.14"], "single_config_entry": true } diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 2622333e15f..2ebd04db4b6 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -46,7 +46,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." }, "services": { "send_text_command": { diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index 16b1463f0f3..3a0b2bc4832 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -186,3 +186,13 @@ STT_LANGUAGES = [ "yue-Hant-HK", "zu-ZA", ] + +# This allows us to support HA's standard codes (e.g., zh-CN) while +# sending the correct code to the Google API (e.g., cmn-Hans-CN). +HA_TO_GOOGLE_STT_LANG_MAP = { + "zh-CN": "cmn-Hans-CN", # Chinese (Mandarin, Simplified, China) + "zh-HK": "yue-Hant-HK", # Chinese (Cantonese, Traditional, Hong Kong) + "zh-TW": "cmn-Hant-TW", # Chinese (Mandarin, Traditional, Taiwan) + "he-IL": "iw-IL", # Hebrew (Google uses 'iw' legacy code) + "nb-NO": "no-NO", # Norwegian Bokmål +} diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index cd5055383ea..ea438b01cdd 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -8,6 +8,7 @@ import logging from google.api_core.exceptions import GoogleAPIError, Unauthenticated from google.api_core.retry import AsyncRetry from google.cloud import speech_v1 +from propcache.api import cached_property from homeassistant.components.stt import ( AudioBitRates, @@ -30,6 +31,7 @@ from .const import ( CONF_STT_MODEL, DEFAULT_STT_MODEL, DOMAIN, + HA_TO_GOOGLE_STT_LANG_MAP, STT_LANGUAGES, ) @@ -68,10 +70,14 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): self._client = client self._model = entry.options.get(CONF_STT_MODEL, DEFAULT_STT_MODEL) - @property + @cached_property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return STT_LANGUAGES + # Combine the native Google languages and the standard HA languages. + # A set is used to automatically handle duplicates. + supported = set(STT_LANGUAGES) + supported.update(HA_TO_GOOGLE_STT_LANG_MAP.keys()) + return sorted(supported) @property def supported_formats(self) -> list[AudioFormats]: @@ -102,6 +108,10 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" + language_code = HA_TO_GOOGLE_STT_LANG_MAP.get( + metadata.language, metadata.language + ) + streaming_config = speech_v1.StreamingRecognitionConfig( config=speech_v1.RecognitionConfig( encoding=( @@ -110,7 +120,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): else speech_v1.RecognitionConfig.AudioEncoding.LINEAR16 ), sample_rate_hertz=metadata.sample_rate, - language_code=metadata.language, + language_code=language_code, model=self._model, ) ) @@ -127,7 +137,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): try: responses = await self._client.streaming_recognize( requests=request_generator(), - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 16519645dee..817c424d1fc 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -218,7 +218,7 @@ class BaseGoogleCloudProvider: response = await self._client.synthesize_speech( request, - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 3a7d160399d..a1fd5ea0f9b 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,13 +2,12 @@ from __future__ import annotations -import asyncio -import mimetypes +from functools import partial from pathlib import Path +from types import MappingProxyType from google.genai import Client from google.genai.errors import APIError, ClientError -from google.genai.types import File, FileState from requests.exceptions import Timeout import voluptuous as vol @@ -35,14 +34,20 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_CHAT_MODEL, CONF_PROMPT, + DEFAULT_AI_TASK_NAME, + DEFAULT_STT_NAME, + DEFAULT_TITLE, + DEFAULT_TTS_NAME, DOMAIN, - FILE_POLLING_INTERVAL_SECONDS, LOGGER, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_STT_OPTIONS, + RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) +from .entity import async_prepare_files_for_prompt SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" @@ -50,7 +55,9 @@ CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( + Platform.AI_TASK, Platform.CONVERSATION, + Platform.STT, Platform.TTS, ) @@ -85,58 +92,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client = config_entry.runtime_data - def append_files_to_prompt(): - image_filenames = call.data[CONF_IMAGE_FILENAME] - filenames = call.data[CONF_FILENAMES] - for filename in set(image_filenames + filenames): + files = call.data[CONF_IMAGE_FILENAME] + call.data[CONF_FILENAMES] + + if files: + for filename in files: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( f"Cannot read `{filename}`, no access to path; " "`allowlist_external_dirs` may need to be adjusted in " "`configuration.yaml`" ) - if not Path(filename).exists(): - raise HomeAssistantError(f"`{filename}` does not exist") - mimetype = mimetypes.guess_type(filename)[0] - with open(filename, "rb") as file: - uploaded_file = client.files.upload( - file=file, config={"mime_type": mimetype} - ) - prompt_parts.append(uploaded_file) - async def wait_for_file_processing(uploaded_file: File) -> None: - """Wait for file processing to complete.""" - while True: - uploaded_file = await client.aio.files.get( - name=uploaded_file.name, - config={"http_options": {"timeout": TIMEOUT_MILLIS}}, + prompt_parts.extend( + await async_prepare_files_for_prompt( + hass, client, [Path(filename) for filename in files] ) - if uploaded_file.state not in ( - FileState.STATE_UNSPECIFIED, - FileState.PROCESSING, - ): - break - LOGGER.debug( - "Waiting for file `%s` to be processed, current state: %s", - uploaded_file.name, - uploaded_file.state, - ) - await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) - - if uploaded_file.state == FileState.FAILED: - raise HomeAssistantError( - f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}" - ) - - await hass.async_add_executor_job(append_files_to_prompt) - - tasks = [ - asyncio.create_task(wait_for_file_processing(part)) - for part in prompt_parts - if isinstance(part, File) and part.state != FileState.ACTIVE - ] - async with asyncio.timeout(TIMEOUT_MILLIS / 1000): - await asyncio.gather(*tasks) + ) try: response = await client.aio.models.generate_content( @@ -153,7 +124,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}" ) - if not response.candidates[0].content.parts: + if ( + not response.candidates + or not response.candidates[0].content + or not response.candidates[0].content.parts + ): raise HomeAssistantError("Unknown error generating content") return {"text": response.text} @@ -184,13 +159,11 @@ async def async_setup_entry( """Set up Google Generative AI Conversation from a config entry.""" try: - - def _init_client() -> Client: - return Client(api_key=entry.data[CONF_API_KEY]) - - client = await hass.async_add_executor_job(_init_client) + client = await hass.async_add_executor_job( + partial(Client, api_key=entry.data[CONF_API_KEY]) + ) await client.aio.models.get( - model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + model=RECOMMENDED_CHAT_MODEL, config={"http_options": {"timeout": TIMEOUT_MILLIS}}, ) except (APIError, Timeout) as err: @@ -204,6 +177,8 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -217,14 +192,25 @@ async def async_unload_entry( return True +async def async_update_options( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -238,30 +224,71 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + if use_existing: + hass.config_entries.async_add_subentry( + parent_entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -271,12 +298,126 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_and_stt_subentries(hass, entry) hass.config_entries.async_update_entry( entry, + title=DEFAULT_TITLE, options={}, version=2, + minor_version=4, ) + + +async def async_migrate_entry( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Add TTS subentry which was missing in 2025.7.0b0 + if not any( + subentry.subentry_type == "tts" for subentry in entry.subentries.values() + ): + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) + + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + if entry.version == 2 and entry.minor_version == 2: + _add_ai_task_and_stt_subentries(hass, entry) + hass.config_entries.async_update_entry(entry, minor_version=3) + + if entry.version == 2 and entry.minor_version == 3: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=4) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True + + +def _add_ai_task_and_stt_subentries( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> None: + """Add AI Task and STT subentries to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_STT_OPTIONS), + subentry_type="stt", + title=DEFAULT_STT_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py new file mode 100644 index 00000000000..4ffca835fed --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -0,0 +1,81 @@ +"""AI Task integration for Google Generative AI Conversation.""" + +from __future__ import annotations + +from json import JSONDecodeError + +from homeassistant.components import ai_task, conversation +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.util.json import json_loads + +from .const import LOGGER +from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [GoogleGenerativeAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleGenerativeAITaskEntity( + ai_task.AITaskEntity, + GoogleGenerativeAILLMBaseEntity, +): + """Google Generative AI AI Task entity.""" + + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + chat_log.content[-1], + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + + try: + data = json_loads(text) + except JSONDecodeError as err: + LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 4b7c7a0dd47..9048304a006 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from functools import partial import logging from typing import Any, cast @@ -46,14 +47,25 @@ from .const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_STT_PROMPT, + DEFAULT_TITLE, + DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, TIMEOUT_MILLIS, ) @@ -66,19 +78,15 @@ STEP_API_DATA_SCHEMA = vol.Schema( } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - -async def validate_input(data: dict[str, Any]) -> None: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = genai.Client(api_key=data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(genai.Client, api_key=data[CONF_API_KEY]) + ) await client.aio.models.list( config={ "http_options": { @@ -93,6 +101,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 + MINOR_VERSION = 4 async def async_step_api( self, user_input: dict[str, Any] | None = None @@ -102,7 +111,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match(user_input) try: - await validate_input(user_input) + await validate_input(self.hass, user_input) except (APIError, Timeout) as err: if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err): errors["base"] = "invalid_auth" @@ -118,15 +127,33 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) return self.async_create_entry( - title="Google Generative AI", + title=DEFAULT_TITLE, data=user_input, subentries=[ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -172,10 +199,15 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": LLMSubentryFlowHandler, + "stt": LLMSubentryFlowHandler, + "tts": LLMSubentryFlowHandler, + "ai_task_data": LLMSubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): +class LLMSubentryFlowHandler(ConfigSubentryFlow): """Flow for managing conversation subentries.""" last_rendered_recommended = False @@ -202,7 +234,15 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input is None: if self._is_new: - options = RECOMMENDED_OPTIONS.copy() + options: dict[str, Any] + if self._subentry_type == "tts": + options = RECOMMENDED_TTS_OPTIONS.copy() + elif self._subentry_type == "ai_task_data": + options = RECOMMENDED_AI_TASK_OPTIONS.copy() + elif self._subentry_type == "stt": + options = RECOMMENDED_STT_OPTIONS.copy() + else: + options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: # If this is a reconfiguration, we need to copy the existing options # so that we can show the current values in the form. @@ -216,7 +256,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) - # Don't allow to save options that enable the Google Seearch tool with an Assist API + # Don't allow to save options that enable the Google Search tool with an Assist API if not ( user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True @@ -240,7 +280,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): options = user_input schema = await google_generative_ai_config_option_schema( - self.hass, self._is_new, options, self._genai_client + self.hass, self._is_new, self._subentry_type, options, self._genai_client ) return self.async_show_form( step_id="set_options", data_schema=vol.Schema(schema), errors=errors @@ -253,6 +293,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): async def google_generative_ai_config_option_schema( hass: HomeAssistant, is_new: bool, + subentry_type: str, options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: @@ -270,26 +311,55 @@ async def google_generative_ai_config_option_schema( suggested_llm_apis = [suggested_llm_apis] if is_new: + if CONF_NAME in options: + default_name = options[CONF_NAME] + elif subentry_type == "tts": + default_name = DEFAULT_TTS_NAME + elif subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME + elif subentry_type == "stt": + default_name = DEFAULT_STT_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { - vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + vol.Required(CONF_NAME, default=default_name): str, } else: schema = {} + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ) + elif subentry_type == "stt": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT) + }, + ): TemplateSelector(), + } + ) + schema.update( { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, @@ -303,14 +373,15 @@ async def google_generative_ai_config_option_schema( api_models = [api_model async for api_model in api_models_pager] models = [ SelectOptionDict( - label=api_model.display_name, + label=api_model.name.lstrip("models/"), value=api_model.name, ) - for api_model in sorted(api_models, key=lambda x: x.display_name or "") + for api_model in sorted( + api_models, key=lambda x: (x.name or "").lstrip("models/") + ) if ( - api_model.display_name - and api_model.name - and "tts" not in api_model.name + api_model.name + and ("tts" in api_model.name) == (subentry_type == "tts") and "vision" not in api_model.name and api_model.supported_actions and "generateContent" in api_model.supported_actions @@ -341,12 +412,19 @@ async def google_generative_ai_config_option_schema( ) ) + if subentry_type == "tts": + default_model = RECOMMENDED_TTS_MODEL + elif subentry_type == "stt": + default_model = RECOMMENDED_STT_MODEL + else: + default_model = RECOMMENDED_CHAT_MODEL + schema.update( { vol.Optional( CONF_CHAT_MODEL, description={"suggested_value": options.get(CONF_CHAT_MODEL)}, - default=RECOMMENDED_CHAT_MODEL, + default=default_model, ): SelectSelector( SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models) ), @@ -396,13 +474,19 @@ async def google_generative_ai_config_option_schema( }, default=RECOMMENDED_HARM_BLOCK_THRESHOLD, ): harm_block_thresholds_selector, - vol.Optional( - CONF_USE_GOOGLE_SEARCH_TOOL, - description={ - "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), - }, - default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, - ): bool, } ) + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_USE_GOOGLE_SEARCH_TOOL, + description={ + "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), + }, + default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + ): bool, + } + ) + return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 0735e9015c2..ba7af5147c5 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -2,17 +2,27 @@ import logging -DOMAIN = "google_generative_ai_conversation" +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + LOGGER = logging.getLogger(__package__) -CONF_PROMPT = "prompt" + +DOMAIN = "google_generative_ai_conversation" +DEFAULT_TITLE = "Google Generative AI" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" +DEFAULT_STT_NAME = "Google AI STT" +DEFAULT_TTS_NAME = "Google AI TTS" +DEFAULT_AI_TASK_NAME = "Google AI Task" + +CONF_PROMPT = "prompt" +DEFAULT_STT_PROMPT = "Transcribe the attached audio" -ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" -RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" +RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL +RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" @@ -20,7 +30,7 @@ RECOMMENDED_TOP_P = 0.95 CONF_TOP_K = "top_k" RECOMMENDED_TOP_K = 64 CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 1500 +RECOMMENDED_MAX_TOKENS = 3000 CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" @@ -31,3 +41,22 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 FILE_POLLING_INTERVAL_SECONDS = 0.05 + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_RECOMMENDED: True, +} + +RECOMMENDED_STT_OPTIONS = { + CONF_PROMPT: DEFAULT_STT_PROMPT, + CONF_RECOMMENDED: True, +} + +RECOMMENDED_TTS_OPTIONS = { + CONF_RECOMMENDED: True, +} + +RECOMMENDED_AI_TASK_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index d8eae3f6d0d..d804073bfb4 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,16 +4,14 @@ from __future__ import annotations from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_PROMPT, DOMAIN, LOGGER -from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity +from .const import CONF_PROMPT, DOMAIN +from .entity import GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -57,13 +55,7 @@ class GoogleGenerativeAIConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -90,23 +82,4 @@ class GoogleGenerativeAIConversationEntity( await self._async_handle_chat_log(chat_log) - response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - LOGGER.error( - "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", - chat_log.content[-1], - ) - raise HomeAssistantError(ERROR_GETTING_RESPONSE) - response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/google_generative_ai_conversation/diagnostics.py b/homeassistant/components/google_generative_ai_conversation/diagnostics.py index 13643da7e00..34b9f762355 100644 --- a/homeassistant/components/google_generative_ai_conversation/diagnostics.py +++ b/homeassistant/components/google_generative_ai_conversation/diagnostics.py @@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics( "title": entry.title, "data": entry.data, "options": entry.options, + "subentries": dict(entry.subentries), }, TO_REDACT, ) diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index d4b0ec2bbd0..90c144530e0 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -2,29 +2,40 @@ from __future__ import annotations +import asyncio import codecs -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Callable from dataclasses import replace -from typing import Any, cast +import mimetypes +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast +from google.genai import Client from google.genai.errors import APIError, ClientError from google.genai.types import ( AutomaticFunctionCallingConfig, Content, + ContentDict, + File, + FileState, FunctionDeclaration, GenerateContentConfig, GenerateContentResponse, GoogleSearch, HarmCategory, Part, + PartUnionDict, SafetySetting, Schema, Tool, + ToolListUnion, ) +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity @@ -41,6 +52,7 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, + FILE_POLLING_INTERVAL_SECONDS, LOGGER, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -48,8 +60,12 @@ from .const import ( RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + TIMEOUT_MILLIS, ) +if TYPE_CHECKING: + from . import GoogleGenerativeAIConfigEntry + # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -224,7 +240,7 @@ def _convert_content( async def _transform_stream( - result: AsyncGenerator[GenerateContentResponse], + result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True try: @@ -301,7 +317,12 @@ async def _transform_stream( class GoogleGenerativeAILLMBaseEntity(Entity): """Google Generative AI base entity.""" - def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: + def __init__( + self, + entry: GoogleGenerativeAIConfigEntry, + subentry: ConfigSubentry, + default_model: str = RECOMMENDED_CHAT_MODEL, + ) -> None: """Initialize the agent.""" self.entry = entry self.subentry = subentry @@ -312,18 +333,19 @@ class GoogleGenerativeAILLMBaseEntity(Entity): identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Google", - model="Generative AI", + model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1], entry_type=dr.DeviceEntryType.SERVICE, ) async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, + structure: vol.Schema | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data - tools: list[Tool | Callable[..., Any]] | None = None + tools: ToolListUnion | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -337,7 +359,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): tools = tools or [] tools.append(Tool(google_search=GoogleSearch())) - model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model_name = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Avoid INVALID_ARGUMENT Developer instruction is not enabled for supports_system_instruction = ( "gemma" not in model_name @@ -354,7 +376,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): else: raise HomeAssistantError("Invalid prompt content") - messages: list[Content] = [] + messages: list[Content | ContentDict] = [] # Google groups tool results, we do not. Group them before sending. tool_results: list[conversation.ToolResultContent] = [] @@ -381,7 +403,10 @@ class GoogleGenerativeAILLMBaseEntity(Entity): # The SDK requires the first message to be a user message # This is not the case if user used `start_conversation` # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 - if messages and messages[0].role != "user": + if messages and ( + (isinstance(messages[0], Content) and messages[0].role != "user") + or (isinstance(messages[0], dict) and messages[0]["role"] != "user") + ): messages.insert( 0, Content(role="user", parts=[Part.from_text(text=" ")]), @@ -389,48 +414,26 @@ class GoogleGenerativeAILLMBaseEntity(Entity): if tool_results: messages.append(_create_google_tool_response_content(tool_results)) - generateContentConfig = GenerateContentConfig( - temperature=self.entry.options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), - top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - max_output_tokens=self.entry.options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - safety_settings=[ - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, - threshold=self.entry.options.get( - CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HARASSMENT, - threshold=self.entry.options.get( - CONF_HARASSMENT_BLOCK_THRESHOLD, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, - threshold=self.entry.options.get( - CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, - threshold=self.entry.options.get( - CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - ], - tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, - automatic_function_calling=AutomaticFunctionCallingConfig( - disable=True, maximum_remote_calls=None - ), + generateContentConfig = self.create_generate_content_config() + generateContentConfig.tools = tools or None + generateContentConfig.system_instruction = ( + prompt if supports_system_instruction else None ) + generateContentConfig.automatic_function_calling = ( + AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None) + ) + if structure: + generateContentConfig.response_mime_type = "application/json" + generateContentConfig.response_schema = _format_schema( + convert( + structure, + custom_serializer=( + chat_log.llm_api.custom_serializer + if chat_log.llm_api + else llm.selector_serializer + ), + ) + ) if not supports_system_instruction: messages = [ @@ -443,7 +446,15 @@ class GoogleGenerativeAILLMBaseEntity(Entity): ) user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) - chat_request: str | list[Part] = user_message.content + chat_request: list[PartUnionDict] = [user_message.content] + if user_message.attachments: + files = await async_prepare_files_for_prompt( + self.hass, + self._genai_client, + [a.path for a in user_message.attachments], + ) + chat_request = [*chat_request, *files] + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: @@ -459,16 +470,120 @@ class GoogleGenerativeAILLMBaseEntity(Entity): error = ERROR_GETTING_RESPONSE raise HomeAssistantError(error) from err - chat_request = _create_google_tool_response_parts( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_response_generator), - ) - if isinstance(content, conversation.ToolResultContent) - ] + chat_request = list( + _create_google_tool_response_parts( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_response_generator), + ) + if isinstance(content, conversation.ToolResultContent) + ] + ) ) if not chat_log.unresponded_tool_results: break + + def create_generate_content_config(self) -> GenerateContentConfig: + """Create the GenerateContentConfig for the LLM.""" + options = self.subentry.data + return GenerateContentConfig( + temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K), + top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + safety_settings=[ + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=options.get( + CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=options.get( + CONF_HARASSMENT_BLOCK_THRESHOLD, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=options.get( + CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=options.get( + CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + ], + ) + + +async def async_prepare_files_for_prompt( + hass: HomeAssistant, client: Client, files: list[Path] +) -> list[File]: + """Upload files so they can be attached to a prompt. + + Caller needs to ensure that the files are allowed. + """ + + def upload_files() -> list[File]: + prompt_parts: list[File] = [] + for filename in files: + if not filename.exists(): + raise HomeAssistantError(f"`{filename}` does not exist") + mimetype = mimetypes.guess_type(filename)[0] + prompt_parts.append( + client.files.upload( + file=filename, + config={ + "mime_type": mimetype, + "display_name": filename.name, + }, + ) + ) + return prompt_parts + + async def wait_for_file_processing(uploaded_file: File) -> None: + """Wait for file processing to complete.""" + first = True + while uploaded_file.state in ( + FileState.STATE_UNSPECIFIED, + FileState.PROCESSING, + ): + if first: + first = False + else: + LOGGER.debug( + "Waiting for file `%s` to be processed, current state: %s", + uploaded_file.name, + uploaded_file.state, + ) + await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) + + uploaded_file = await client.aio.files.get( + name=uploaded_file.name or "", + config={"http_options": {"timeout": TIMEOUT_MILLIS}}, + ) + + if uploaded_file.state == FileState.FAILED: + raise HomeAssistantError( + f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message if uploaded_file.error else 'unknown'}" + ) + + prompt_parts = await hass.async_add_executor_job(upload_files) + + tasks = [ + asyncio.create_task(wait_for_file_processing(part)) + for part in prompt_parts + if part.state != FileState.ACTIVE + ] + async with asyncio.timeout(TIMEOUT_MILLIS / 1000): + await asyncio.gather(*tasks) + + return prompt_parts diff --git a/homeassistant/components/google_generative_ai_conversation/helpers.py b/homeassistant/components/google_generative_ai_conversation/helpers.py new file mode 100644 index 00000000000..3d053aa9f1a --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/helpers.py @@ -0,0 +1,73 @@ +"""Helper classes for Google Generative AI integration.""" + +from __future__ import annotations + +from contextlib import suppress +import io +import wave + +from homeassistant.exceptions import HomeAssistantError + +from .const import LOGGER + + +def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes: + """Generate a WAV file header for the given audio data and parameters. + + Args: + audio_data: The raw audio data as a bytes object. + mime_type: Mime type of the audio data. + + Returns: + A bytes object representing the WAV file header. + + """ + parameters = _parse_audio_mime_type(mime_type) + + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(parameters["bits_per_sample"] // 8) + wf.setframerate(parameters["rate"]) + wf.writeframes(audio_data) + + return wav_buffer.getvalue() + + +# Below code is from https://aistudio.google.com/app/generate-speech +# when you select "Get SDK code to generate speech". +def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: + """Parse bits per sample and rate from an audio MIME type string. + + Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". + + Args: + mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). + + Returns: + A dictionary with "bits_per_sample" and "rate" keys. Values will be + integers if found, otherwise None. + + """ + if not mime_type.startswith("audio/L"): + LOGGER.warning("Received unexpected MIME type %s", mime_type) + raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") + + bits_per_sample = 16 + rate = 24000 + + # Extract rate from parameters + parts = mime_type.split(";") + for param in parts: # Skip the main type part + param = param.strip() + if param.lower().startswith("rate="): + # Handle cases like "rate=" with no value or non-integer value and keep rate as default + with suppress(ValueError, IndexError): + rate_str = param.split("=", 1)[1] + rate = int(rate_str) + elif param.startswith("audio/L"): + # Keep bits_per_sample as default if conversion fails + with suppress(ValueError, IndexError): + bits_per_sample = int(param.split("L", 1)[1]) + + return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 25e44964a6d..ce089440b97 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-genai==1.7.0"] + "requirements": ["google-genai==1.29.0"] } diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index e523aecbaec..545436da590 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -29,13 +29,12 @@ "reconfigure": "Reconfigure conversation agent" }, "entry_type": "Conversation agent", - "step": { "set_options": { "data": { "name": "[%key:common::config_flow::data::name%]", "recommended": "Recommended model settings", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", @@ -61,6 +60,94 @@ "error": { "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } + }, + "stt": { + "initiate_flow": { + "user": "Add Speech-to-Text service", + "reconfigure": "Reconfigure Speech-to-Text service" + }, + "entry_type": "Speech-to-Text", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "prompt": "[%key:common::config_flow::data::prompt%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + }, + "data_description": { + "prompt": "Instruct how the LLM should transcribe the audio." + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "tts": { + "initiate_flow": { + "user": "Add Text-to-Speech service", + "reconfigure": "Reconfigure Text-to-Speech service" + }, + "entry_type": "Text-to-Speech", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "ai_task_data": { + "initiate_flow": { + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" + }, + "entry_type": "AI task", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } } }, "services": { diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py new file mode 100644 index 00000000000..f9b91ff6685 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -0,0 +1,259 @@ +"""Speech to text support for Google Generative AI.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable + +from google.genai.errors import APIError, ClientError +from google.genai.types import Part + +from homeassistant.components import stt +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + LOGGER, + RECOMMENDED_STT_MODEL, +) +from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + 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( + [GoogleGenerativeAISttEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleGenerativeAISttEntity( + stt.SpeechToTextEntity, GoogleGenerativeAILLMBaseEntity +): + """Google Generative AI speech-to-text entity.""" + + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the STT entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_STT_MODEL) + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return [ + "af-ZA", + "am-ET", + "ar-AE", + "ar-BH", + "ar-DZ", + "ar-EG", + "ar-IL", + "ar-IQ", + "ar-JO", + "ar-KW", + "ar-LB", + "ar-MA", + "ar-OM", + "ar-PS", + "ar-QA", + "ar-SA", + "ar-TN", + "ar-YE", + "az-AZ", + "bg-BG", + "bn-BD", + "bn-IN", + "bs-BA", + "ca-ES", + "cs-CZ", + "da-DK", + "de-AT", + "de-CH", + "de-DE", + "el-GR", + "en-AU", + "en-CA", + "en-GB", + "en-GH", + "en-HK", + "en-IE", + "en-IN", + "en-KE", + "en-NG", + "en-NZ", + "en-PH", + "en-PK", + "en-SG", + "en-TZ", + "en-US", + "en-ZA", + "es-AR", + "es-BO", + "es-CL", + "es-CO", + "es-CR", + "es-DO", + "es-EC", + "es-ES", + "es-GT", + "es-HN", + "es-MX", + "es-NI", + "es-PA", + "es-PE", + "es-PR", + "es-PY", + "es-SV", + "es-US", + "es-UY", + "es-VE", + "et-EE", + "eu-ES", + "fa-IR", + "fi-FI", + "fil-PH", + "fr-BE", + "fr-CA", + "fr-CH", + "fr-FR", + "ga-IE", + "gl-ES", + "gu-IN", + "he-IL", + "hi-IN", + "hr-HR", + "hu-HU", + "hy-AM", + "id-ID", + "is-IS", + "it-CH", + "it-IT", + "iw-IL", + "ja-JP", + "jv-ID", + "ka-GE", + "kk-KZ", + "km-KH", + "kn-IN", + "ko-KR", + "lb-LU", + "lo-LA", + "lt-LT", + "lv-LV", + "mk-MK", + "ml-IN", + "mn-MN", + "mr-IN", + "ms-MY", + "my-MM", + "nb-NO", + "ne-NP", + "nl-BE", + "nl-NL", + "no-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "si-LK", + "sk-SK", + "sl-SI", + "sq-AL", + "sr-RS", + "su-ID", + "sv-SE", + "sw-KE", + "sw-TZ", + "ta-IN", + "ta-LK", + "ta-MY", + "ta-SG", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "ur-IN", + "ur-PK", + "uz-UZ", + "vi-VN", + "zh-CN", + "zh-HK", + "zh-TW", + "zu-ZA", + ] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + # https://ai.google.dev/gemini-api/docs/audio#supported-formats + 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_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported sample rates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + # Per https://ai.google.dev/gemini-api/docs/audio + # If the audio source contains multiple channels, Gemini combines those channels into a single channel. + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + audio_data = b"" + async for chunk in stream: + audio_data += chunk + if metadata.format == stt.AudioFormats.WAV: + audio_data = convert_to_wav( + audio_data, + f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}", + ) + + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL), + contents=[ + self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT), + Part.from_bytes( + data=audio_data, + mime_type=f"audio/{metadata.format.value}", + ), + ], + config=self.create_generate_content_config(), + ) + except (APIError, ClientError, ValueError) as err: + LOGGER.error("Error during STT: %s", err) + else: + if response.text: + return stt.SpeechResult( + response.text, + stt.SpeechResultState.SUCCESS, + ) + + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 50baec67db2..ed956bdb13c 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -2,13 +2,12 @@ from __future__ import annotations -from contextlib import suppress -import io -import logging +from collections.abc import Mapping from typing import Any -import wave from google.genai import types +from google.genai.errors import APIError, ClientError +from propcache.api import cached_property from homeassistant.components.tts import ( ATTR_VOICE, @@ -16,15 +15,14 @@ from homeassistant.components.tts import ( TtsAudioType, Voice, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL +from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav async def async_setup_entry( @@ -32,20 +30,31 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up TTS entity.""" - tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry) - async_add_entities([tts_entity]) + """Set up TTS entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "tts": + continue + + async_add_entities( + [GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) -class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): +class GoogleGenerativeAITextToSpeechEntity( + TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity +): """Google Generative AI text-to-speech entity.""" - _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + _attr_supported_options = [ATTR_VOICE] # See https://ai.google.dev/gemini-api/docs/speech-generation#languages + # Note the documentation might not be up to date, e.g. el-GR is not listed + # there but is supported. _attr_supported_languages = [ "ar-EG", "bn-BD", "de-DE", + "el-GR", "en-IN", "en-US", "es-US", @@ -68,6 +77,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): "uk-UA", "vi-VN", ] + # Unused, but required by base class. + # The Gemini TTS models detect the input language automatically. _attr_default_language = "en-US" # See https://ai.google.dev/gemini-api/docs/speech-generation#voices _supported_voices = [ @@ -106,110 +117,70 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): ) ] - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Google Generative AI Conversation speech entity.""" - self.entry = entry - self._attr_name = "Google Generative AI TTS" - self._attr_unique_id = f"{entry.entry_id}_tts" - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="Google", - model="Generative AI", - entry_type=dr.DeviceEntryType.SERVICE, - ) - self._genai_client = entry.runtime_data - self._default_voice_id = self._supported_voices[0].voice_id + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the TTS entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL) @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: + def async_get_supported_voices(self, language: str) -> list[Voice]: """Return a list of supported voices for a language.""" return self._supported_voices + @cached_property + def default_options(self) -> Mapping[str, Any]: + """Return a mapping with the default options.""" + return { + ATTR_VOICE: self._supported_voices[0].voice_id, + } + async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine.""" - try: - response = self._genai_client.models.generate_content( - model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL), - contents=message, - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig( - voice_name=options.get( - ATTR_VOICE, self._default_voice_id - ) - ) - ) - ), - ), + config = self.create_generate_content_config() + config.response_modalities = ["AUDIO"] + config.speech_config = types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=options[ATTR_VOICE] + ) ) + ) + + def _extract_audio_parts( + response: types.GenerateContentResponse, + ) -> tuple[bytes, str]: + if ( + not response.candidates + or not response.candidates[0].content + or not response.candidates[0].content.parts + or not response.candidates[0].content.parts[0].inline_data + ): + raise ValueError("No content returned from TTS generation") data = response.candidates[0].content.parts[0].inline_data.data mime_type = response.candidates[0].content.parts[0].inline_data.mime_type - except Exception as exc: - _LOGGER.warning( - "Error during processing of TTS request %s", exc, exc_info=True + + if not isinstance(data, bytes): + raise TypeError( + f"Expected bytes for audio data, got {type(data).__name__}" + ) + if not isinstance(mime_type, str): + raise TypeError( + f"Expected str for mime_type, got {type(mime_type).__name__}" + ) + + return data, mime_type + + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL), + contents=message, + config=config, ) + + data, mime_type = _extract_audio_parts(response) + except (APIError, ClientError, ValueError, TypeError) as exc: + LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc - return "wav", self._convert_to_wav(data, mime_type) - - def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes: - """Generate a WAV file header for the given audio data and parameters. - - Args: - audio_data: The raw audio data as a bytes object. - mime_type: Mime type of the audio data. - - Returns: - A bytes object representing the WAV file header. - - """ - parameters = self._parse_audio_mime_type(mime_type) - - wav_buffer = io.BytesIO() - with wave.open(wav_buffer, "wb") as wf: - wf.setnchannels(1) - wf.setsampwidth(parameters["bits_per_sample"] // 8) - wf.setframerate(parameters["rate"]) - wf.writeframes(audio_data) - - return wav_buffer.getvalue() - - def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]: - """Parse bits per sample and rate from an audio MIME type string. - - Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". - - Args: - mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). - - Returns: - A dictionary with "bits_per_sample" and "rate" keys. Values will be - integers if found, otherwise None. - - """ - if not mime_type.startswith("audio/L"): - _LOGGER.warning("Received unexpected MIME type %s", mime_type) - raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") - - bits_per_sample = 16 - rate = 24000 - - # Extract rate from parameters - parts = mime_type.split(";") - for param in parts: # Skip the main type part - param = param.strip() - if param.lower().startswith("rate="): - # Handle cases like "rate=" with no value or non-integer value and keep rate as default - with suppress(ValueError, IndexError): - rate_str = param.split("=", 1)[1] - rate = int(rate_str) - elif param.startswith("audio/L"): - # Keep bits_per_sample as default if conversion fails - with suppress(ValueError, IndexError): - bits_per_sample = int(param.split("L", 1)[1]) - - return {"bits_per_sample": bits_per_sample, "rate": rate} + return "wav", convert_to_wav(data, mime_type) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index f46d33fda09..b114c3d9225 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name (case sensitive)", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or a zone's friendly name (case-sensitive)", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index ee8d11d035d..88f7d9017ab 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -56,12 +56,12 @@ async def basic_group_options_schema( entity_selector: selector.Selector[Any] | vol.Schema if handler is None: entity_selector = selector.selector( - {"entity": {"domain": domain, "multiple": True}} + {"entity": {"domain": domain, "multiple": True, "reorder": True}} ) else: entity_selector = entity_selector_without_own_entities( cast(SchemaOptionsFlowHandler, handler.parent_handler), - selector.EntitySelectorConfig(domain=domain, multiple=True), + selector.EntitySelectorConfig(domain=domain, multiple=True, reorder=True), ) return vol.Schema( @@ -78,7 +78,9 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema: { vol.Required("name"): selector.TextSelector(), vol.Required(CONF_ENTITIES): selector.EntitySelector( - selector.EntitySelectorConfig(domain=domain, multiple=True), + selector.EntitySelectorConfig( + domain=domain, multiple=True, reorder=True + ), ), vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(), } @@ -139,13 +141,25 @@ async def light_switch_options_schema( """Generate options schema.""" return (await basic_group_options_schema(domain, handler)).extend( { - vol.Required( - CONF_ALL, default=False, description={"advanced": True} - ): selector.BooleanSelector(), + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), } ) +LIGHT_CONFIG_SCHEMA = basic_group_config_schema("light").extend( + { + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), + } +) + + +SWITCH_CONFIG_SCHEMA = basic_group_config_schema("switch").extend( + { + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), + } +) + + GROUP_TYPES = [ "binary_sensor", "button", @@ -210,7 +224,7 @@ CONFIG_FLOW = { validate_user_input=set_group_type("fan"), ), "light": SchemaFlowFormStep( - basic_group_config_schema("light"), + LIGHT_CONFIG_SCHEMA, preview="group", validate_user_input=set_group_type("light"), ), @@ -235,7 +249,7 @@ CONFIG_FLOW = { validate_user_input=set_group_type("sensor"), ), "switch": SchemaFlowFormStep( - basic_group_config_schema("switch"), + SWITCH_CONFIG_SCHEMA, preview="group", validate_user_input=set_group_type("switch"), ), diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index b80b78027bf..8a9f4377a62 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -21,12 +21,14 @@ }, "binary_sensor": { "title": "[%key:component::group::config::step::user::title%]", - "description": "If \"all entities\" is enabled, the group's state is on only if all members are on. If \"all entities\" is disabled, the group's state is on if any member is on.", "data": { "all": "All entities", "entities": "Members", "hide_members": "Hide members", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "If enabled, the group's state is on only if all members are on. If disabled, the group's state is on if any member is on." } }, "button": { @@ -64,9 +66,13 @@ "light": { "title": "[%key:component::group::config::step::user::title%]", "data": { + "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } }, "lock": { @@ -105,14 +111,21 @@ "device_class": "Device class", "state_class": "State class", "unit_of_measurement": "Unit of measurement" + }, + "data_description": { + "ignore_non_numeric": "If enabled, the group's state is calculated if at least one member has a numerical value. If disabled, the group's state is calculated only if all group members have numerical values." } }, "switch": { "title": "[%key:component::group::config::step::user::title%]", "data": { + "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } } } @@ -120,11 +133,13 @@ "options": { "step": { "binary_sensor": { - "description": "[%key:component::group::config::step::binary_sensor::description%]", "data": { "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } }, "button": { @@ -146,11 +161,13 @@ } }, "light": { - "description": "[%key:component::group::config::step::binary_sensor::description%]", "data": { "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } }, "lock": { @@ -172,7 +189,6 @@ } }, "sensor": { - "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { "ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", @@ -182,14 +198,19 @@ "device_class": "[%key:component::group::config::step::sensor::data::device_class%]", "state_class": "[%key:component::group::config::step::sensor::data::state_class%]", "unit_of_measurement": "[%key:component::group::config::step::sensor::data::unit_of_measurement%]" + }, + "data_description": { + "ignore_non_numeric": "[%key:component::group::config::step::sensor::data_description::ignore_non_numeric%]" } }, "switch": { - "description": "[%key:component::group::config::step::binary_sensor::description%]", "data": { "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } } } diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 66df76bc6cb..39270788780 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -1,21 +1,104 @@ """The Growatt server PV inverter sensor integration.""" -from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from collections.abc import Mapping -from .const import PLATFORMS +import growattServer + +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import ( + CONF_PLANT_ID, + DEFAULT_PLANT_ID, + DEFAULT_URL, + DEPRECATED_URLS, + LOGIN_INVALID_AUTH_CODE, + PLATFORMS, +) +from .coordinator import GrowattConfigEntry, GrowattCoordinator +from .models import GrowattRuntimeData + + +def get_device_list( + api: growattServer.GrowattApi, config: Mapping[str, str] +) -> tuple[list[dict[str, str]], str]: + """Retrieve the device list for the selected plant.""" + plant_id = config[CONF_PLANT_ID] + + # Log in to api and fetch first plant if no plant id is defined. + login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): + raise ConfigEntryError("Username, Password or URL may be incorrect!") + user_id = login_response["user"]["id"] + if plant_id == DEFAULT_PLANT_ID: + plant_info = api.plant_list(user_id) + plant_id = plant_info["data"][0]["plantId"] + + # Get a list of devices for specified plant to add sensors for. + devices = api.device_list(plant_id) + return devices, plant_id async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry + hass: HomeAssistant, config_entry: GrowattConfigEntry ) -> bool: - """Load the saved entities.""" + """Set up Growatt from a config entry.""" + config = config_entry.data + username = config[CONF_USERNAME] + url = config.get(CONF_URL, DEFAULT_URL) + + # If the URL has been deprecated then change to the default instead + if url in DEPRECATED_URLS: + url = DEFAULT_URL + new_data = dict(config_entry.data) + new_data[CONF_URL] = url + hass.config_entries.async_update_entry(config_entry, data=new_data) + + # Initialise the library with the username & a random id each time it is started + api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) + api.server_url = url + + devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) + + # Create a coordinator for the total sensors + total_coordinator = GrowattCoordinator( + hass, config_entry, plant_id, "total", plant_id + ) + + # Create coordinators for each device + device_coordinators = { + device["deviceSn"]: GrowattCoordinator( + hass, config_entry, device["deviceSn"], device["deviceType"], plant_id + ) + for device in devices + if device["deviceType"] in ["inverter", "tlx", "storage", "mix"] + } + + # Perform the first refresh for the total coordinator + await total_coordinator.async_config_entry_first_refresh() + + # Perform the first refresh for each device coordinator + for device_coordinator in device_coordinators.values(): + await device_coordinator.async_config_entry_first_refresh() + + # Store runtime data in the config entry + config_entry.runtime_data = GrowattRuntimeData( + total_coordinator=total_coordinator, + devices=device_coordinators, + ) + + # Set up all the entities + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - 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, config_entry: GrowattConfigEntry +) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py new file mode 100644 index 00000000000..a1a2fb938f0 --- /dev/null +++ b/homeassistant/components/growatt_server/coordinator.py @@ -0,0 +1,210 @@ +"""Coordinator module for managing Growatt data fetching.""" + +import datetime +import json +import logging +from typing import TYPE_CHECKING, Any + +import growattServer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_URL, DOMAIN +from .models import GrowattRuntimeData + +if TYPE_CHECKING: + from .sensor.sensor_entity_description import GrowattSensorEntityDescription + +type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData] + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator to manage Growatt data fetching.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GrowattConfigEntry, + device_id: str, + device_type: str, + plant_id: str, + ) -> None: + """Initialize the coordinator.""" + self.username = config_entry.data[CONF_USERNAME] + self.password = config_entry.data[CONF_PASSWORD] + self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) + self.api = growattServer.GrowattApi( + add_random_user_id=True, agent_identifier=self.username + ) + + # Set server URL + self.api.server_url = self.url + + self.device_id = device_id + self.device_type = device_type + self.plant_id = plant_id + + # Initialize previous_values to store historical data + self.previous_values: dict[str, Any] = {} + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} ({device_id})", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + def _sync_update_data(self) -> dict[str, Any]: + """Update data via library synchronously.""" + _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) + + # Login in to the Growatt server + self.api.login(self.username, self.password) + + if self.device_type == "total": + total_info = self.api.plant_info(self.device_id) + del total_info["deviceList"] + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency + self.data = total_info + elif self.device_type == "inverter": + self.data = self.api.inverter_detail(self.device_id) + elif self.device_type == "tlx": + tlx_info = self.api.tlx_detail(self.device_id) + self.data = tlx_info["data"] + elif self.device_type == "storage": + storage_info_detail = self.api.storage_params(self.device_id) + storage_energy_overview = self.api.storage_energy_overview( + self.plant_id, self.device_id + ) + self.data = { + **storage_info_detail["storageDetailBean"], + **storage_energy_overview, + } + 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) + mix_system_status = self.api.mix_system_status( + self.device_id, self.plant_id + ) + mix_detail = self.api.mix_detail(self.device_id, self.plant_id) + + # Get the chart data and work out the time of the last entry + mix_chart_entries = mix_detail["chartData"] + sorted_keys = sorted(mix_chart_entries) + + # Create datetime from the latest entry + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) + mix_detail["lastdataupdate"] = datetime.datetime.combine( + date_now, + last_updated_time, # type: ignore[arg-type] + dt_util.get_default_time_zone(), + ) + + # Dashboard data for mix system + dashboard_data = self.api.dashboard_data(self.plant_id) + dashboard_values_for_mix = { + "etouser_combined": float(dashboard_data["etouser"].replace("kWh", "")) + } + self.data = { + **mix_info, + **mix_totals, + **mix_system_status, + **mix_detail, + **dashboard_values_for_mix, + } + _LOGGER.debug( + "Finished updating data for %s (%s)", + self.device_id, + self.device_type, + ) + + return self.data + + async def _async_update_data(self) -> dict[str, Any]: + """Asynchronously update data via library.""" + try: + return await self.hass.async_add_executor_job(self._sync_update_data) + except json.decoder.JSONDecodeError as err: + _LOGGER.error("Unable to fetch data from Growatt server: %s", err) + raise UpdateFailed(f"Error fetching data: {err}") from err + + def get_currency(self): + """Get the currency.""" + return self.data.get("currency") + + def get_data( + self, entity_description: "GrowattSensorEntityDescription" + ) -> str | int | float | None: + """Get the data.""" + variable = entity_description.api_key + api_value = self.data.get(variable) + previous_value = self.previous_values.get(variable) + return_value = api_value + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + entity_description.previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + entity_description.name, + entity_description.previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(entity_description.previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug( + "%s - No drop detected, using API value", entity_description.name + ) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + if entity_description.never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return return_value diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 7b3e67228b1..b6a730835bb 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", "loggers": ["growattServer"], - "requirements": ["growattServer==1.6.0"] + "requirements": ["growattServer==1.7.1"] } diff --git a/homeassistant/components/growatt_server/models.py b/homeassistant/components/growatt_server/models.py new file mode 100644 index 00000000000..8c5f409616a --- /dev/null +++ b/homeassistant/components/growatt_server/models.py @@ -0,0 +1,17 @@ +"""Models for the Growatt server integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .coordinator import GrowattCoordinator + + +@dataclass +class GrowattRuntimeData: + """Runtime data for the Growatt integration.""" + + total_coordinator: GrowattCoordinator + devices: dict[str, GrowattCoordinator] diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index 2794403811d..3a78f26f091 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -2,29 +2,16 @@ from __future__ import annotations -import datetime -import json import logging -import growattServer - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ..const import ( - CONF_PLANT_ID, - DEFAULT_PLANT_ID, - DEFAULT_URL, - DEPRECATED_URLS, - DOMAIN, - LOGIN_INVALID_AUTH_CODE, -) +from ..const import DOMAIN +from ..coordinator import GrowattConfigEntry, GrowattCoordinator from .inverter import INVERTER_SENSOR_TYPES from .mix import MIX_SENSOR_TYPES from .sensor_entity_description import GrowattSensorEntityDescription @@ -34,136 +21,97 @@ from .total import TOTAL_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(minutes=5) - - -def get_device_list(api, config): - """Retrieve the device list for the selected plant.""" - plant_id = config[CONF_PLANT_ID] - - # Log in to api and fetch first plant if no plant id is defined. - login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - if ( - not login_response["success"] - and login_response["msg"] == LOGIN_INVALID_AUTH_CODE - ): - raise ConfigEntryError("Username, Password or URL may be incorrect!") - user_id = login_response["user"]["id"] - if plant_id == DEFAULT_PLANT_ID: - plant_info = api.plant_list(user_id) - plant_id = plant_info["data"][0]["plantId"] - - # Get a list of devices for specified plant to add sensors for. - devices = api.device_list(plant_id) - return [devices, plant_id] - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GrowattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Growatt sensor.""" - config = {**config_entry.data} - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - url = config.get(CONF_URL, DEFAULT_URL) - name = config[CONF_NAME] + # Use runtime_data instead of hass.data + data = config_entry.runtime_data - # If the URL has been deprecated then change to the default instead - if url in DEPRECATED_URLS: - _LOGGER.warning( - "URL: %s has been deprecated, migrating to the latest default: %s", - url, - DEFAULT_URL, - ) - url = DEFAULT_URL - config[CONF_URL] = url - hass.config_entries.async_update_entry(config_entry, data=config) + entities: list[GrowattSensor] = [] - # Initialise the library with the username & a random id each time it is started - api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) - api.server_url = url - - devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) - - probe = GrowattData(api, username, password, plant_id, "total") - entities = [ - GrowattInverter( - probe, - name=f"{name} Total", - unique_id=f"{plant_id}-{description.key}", + # Add total sensors + total_coordinator = data.total_coordinator + entities.extend( + GrowattSensor( + total_coordinator, + name=f"{config_entry.data['name']} Total", + serial_id=config_entry.data["plant_id"], + unique_id=f"{config_entry.data['plant_id']}-{description.key}", description=description, ) for description in TOTAL_SENSOR_TYPES - ] + ) - # Add sensors for each device in the specified plant. - for device in devices: - probe = GrowattData( - api, username, password, device["deviceSn"], device["deviceType"] - ) - sensor_descriptions: tuple[GrowattSensorEntityDescription, ...] = () - if device["deviceType"] == "inverter": - sensor_descriptions = INVERTER_SENSOR_TYPES - elif device["deviceType"] == "tlx": - probe.plant_id = plant_id - sensor_descriptions = TLX_SENSOR_TYPES - elif device["deviceType"] == "storage": - probe.plant_id = plant_id - sensor_descriptions = STORAGE_SENSOR_TYPES - elif device["deviceType"] == "mix": - probe.plant_id = plant_id - sensor_descriptions = MIX_SENSOR_TYPES + # Add sensors for each device + for device_sn, device_coordinator in data.devices.items(): + sensor_descriptions: list = [] + if device_coordinator.device_type == "inverter": + sensor_descriptions = list(INVERTER_SENSOR_TYPES) + elif device_coordinator.device_type == "tlx": + sensor_descriptions = list(TLX_SENSOR_TYPES) + elif device_coordinator.device_type == "storage": + sensor_descriptions = list(STORAGE_SENSOR_TYPES) + elif device_coordinator.device_type == "mix": + sensor_descriptions = list(MIX_SENSOR_TYPES) else: _LOGGER.debug( "Device type %s was found but is not supported right now", - device["deviceType"], + device_coordinator.device_type, ) entities.extend( - [ - GrowattInverter( - probe, - name=f"{device['deviceAilas']}", - unique_id=f"{device['deviceSn']}-{description.key}", - description=description, - ) - for description in sensor_descriptions - ] + GrowattSensor( + device_coordinator, + name=device_sn, + serial_id=device_sn, + unique_id=f"{device_sn}-{description.key}", + description=description, + ) + for description in sensor_descriptions ) - async_add_entities(entities, True) + async_add_entities(entities) -class GrowattInverter(SensorEntity): +class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity): """Representation of a Growatt Sensor.""" _attr_has_entity_name = True - entity_description: GrowattSensorEntityDescription def __init__( - self, probe, name, unique_id, description: GrowattSensorEntityDescription + self, + coordinator: GrowattCoordinator, + name: str, + serial_id: str, + unique_id: str, + description: GrowattSensorEntityDescription, ) -> None: """Initialize a PVOutput sensor.""" - self.probe = probe + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = unique_id self._attr_icon = "mdi:solar-power" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, probe.device_id)}, + identifiers={(DOMAIN, serial_id)}, manufacturer="Growatt", name=name, ) @property - def native_value(self): + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" - result = self.probe.get_data(self.entity_description) - if self.entity_description.precision is not None: + 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 @@ -171,182 +119,5 @@ class GrowattInverter(SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" if self.entity_description.currency: - return self.probe.get_currency() + return self.coordinator.get_currency() return super().native_unit_of_measurement - - def update(self) -> None: - """Get the latest data from the Growat API and updates the state.""" - self.probe.update() - - -class GrowattData: - """The class for handling data retrieval.""" - - def __init__(self, api, username, password, device_id, growatt_type): - """Initialize the probe.""" - - self.growatt_type = growatt_type - self.api = api - self.device_id = device_id - self.plant_id = None - self.data = {} - self.previous_values = {} - self.username = username - self.password = password - - @Throttle(SCAN_INTERVAL) - def update(self): - """Update probe data.""" - self.api.login(self.username, self.password) - _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type) - try: - if self.growatt_type == "total": - total_info = self.api.plant_info(self.device_id) - del total_info["deviceList"] - # PlantMoneyText comes in as "3.1/€" split between value and currency - plant_money_text, currency = total_info["plantMoneyText"].split("/") - total_info["plantMoneyText"] = plant_money_text - total_info["currency"] = currency - self.data = total_info - elif self.growatt_type == "inverter": - inverter_info = self.api.inverter_detail(self.device_id) - self.data = inverter_info - elif self.growatt_type == "tlx": - tlx_info = self.api.tlx_detail(self.device_id) - self.data = tlx_info["data"] - elif self.growatt_type == "storage": - storage_info_detail = self.api.storage_params(self.device_id)[ - "storageDetailBean" - ] - storage_energy_overview = self.api.storage_energy_overview( - self.plant_id, self.device_id - ) - self.data = {**storage_info_detail, **storage_energy_overview} - elif self.growatt_type == "mix": - mix_info = self.api.mix_info(self.device_id) - mix_totals = self.api.mix_totals(self.device_id, self.plant_id) - mix_system_status = self.api.mix_system_status( - self.device_id, self.plant_id - ) - - mix_detail = self.api.mix_detail(self.device_id, self.plant_id) - # Get the chart data and work out the time of the last entry, use this - # as the last time data was published to the Growatt Server - mix_chart_entries = mix_detail["chartData"] - sorted_keys = sorted(mix_chart_entries) - - # Create datetime from the latest entry - date_now = dt_util.now().date() - last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) - mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt_util.get_default_time_zone() - ) - - # Dashboard data is largely inaccurate for mix system but it is the only - # call with the ability to return the combined imported from grid value - # that is the combination of charging AND load consumption - dashboard_data = self.api.dashboard_data(self.plant_id) - # Dashboard values have units e.g. "kWh" as part of their returned - # string, so we remove it - dashboard_values_for_mix = { - # etouser is already used by the results from 'mix_detail' so we - # rebrand it as 'etouser_combined' - "etouser_combined": float( - dashboard_data["etouser"].replace("kWh", "") - ) - } - self.data = { - **mix_info, - **mix_totals, - **mix_system_status, - **mix_detail, - **dashboard_values_for_mix, - } - _LOGGER.debug( - "Finished updating data for %s (%s)", - self.device_id, - self.growatt_type, - ) - except json.decoder.JSONDecodeError: - _LOGGER.error("Unable to fetch data from Growatt server") - - def get_currency(self): - """Get the currency.""" - return self.data.get("currency") - - def get_data(self, entity_description): - """Get the data.""" - _LOGGER.debug( - "Data request for: %s", - entity_description.name, - ) - variable = entity_description.api_key - api_value = self.data.get(variable) - previous_value = self.previous_values.get(variable) - return_value = api_value - - # If we have a 'drop threshold' specified, then check it and correct if needed - if ( - entity_description.previous_value_drop_threshold is not None - and previous_value is not None - and api_value is not None - ): - _LOGGER.debug( - ( - "%s - Drop threshold specified (%s), checking for drop... API" - " Value: %s, Previous Value: %s" - ), - entity_description.name, - entity_description.previous_value_drop_threshold, - api_value, - previous_value, - ) - diff = float(api_value) - float(previous_value) - - # Check if the value has dropped (negative value i.e. < 0) and it has only - # dropped by a small amount, if so, use the previous value. - # Note - The energy dashboard takes care of drops within 10% - # of the current value, however if the value is low e.g. 0.2 - # and drops by 0.1 it classes as a reset. - if -(entity_description.previous_value_drop_threshold) <= diff < 0: - _LOGGER.debug( - ( - "Diff is negative, but only by a small amount therefore not a" - " nightly reset, using previous value (%s) instead of api value" - " (%s)" - ), - previous_value, - api_value, - ) - return_value = previous_value - else: - _LOGGER.debug( - "%s - No drop detected, using API value", entity_description.name - ) - - # Lifetime total values should always be increasing, they will never reset, - # however the API sometimes returns 0 values when the clock turns to 00:00 - # local time in that scenario we should just return the previous value - # Scenarios: - # 1 - System has a genuine 0 value when it it first commissioned: - # - will return 0 until a non-zero value is registered - # 2 - System has been running fine but temporarily resets to 0 briefly - # at midnight: - # - will return the previous value - # 3 - HA is restarted during the midnight 'outage' - Not handled: - # - Previous value will not exist meaning 0 will be returned - # - This is an edge case that would be better handled by looking - # up the previous value of the entity from the recorder - if entity_description.never_resets and api_value == 0 and previous_value: - _LOGGER.debug( - ( - "API value is 0, but this value should never reset, returning" - " previous value (%s) instead" - ), - previous_value, - ) - return_value = previous_value - - self.previous_values[variable] = return_value - - return return_value diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 65f5525d587..192cb62f5df 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -27,7 +27,7 @@ from .const import ( SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -55,7 +55,7 @@ class GuardianData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Elexa Guardian component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py index c48c87afa01..760b9423afd 100644 --- a/homeassistant/components/guardian/entity.py +++ b/homeassistant/components/guardian/entity.py @@ -74,7 +74,7 @@ class ValveControllerEntity(GuardianEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.data[CONF_UID])}, manufacturer="Elexa", - model=self._diagnostics_coordinator.data["firmware"], + sw_version=self._diagnostics_coordinator.data["firmware"], name=f"Guardian valve controller {entry.data[CONF_UID]}", ) self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" diff --git a/homeassistant/components/guardian/services.py b/homeassistant/components/guardian/services.py index 288c6becbee..927be7c54a5 100644 --- a/homeassistant/components/guardian/services.py +++ b/homeassistant/components/guardian/services.py @@ -122,8 +122,9 @@ async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None: ) -def setup_services(hass: HomeAssistant) -> None: - """Register the Renault services.""" +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the guardian services.""" for service_name, schema, method in ( ( SERVICE_NAME_PAIR_SENSOR, diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 217b5e739d1..514a12d26b7 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,19 +1,26 @@ """The habitica integration.""" +from uuid import UUID + from habiticalib import Habitica from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import CONF_API_USER, DOMAIN, X_CLIENT -from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - +HABITICA_KEY: HassKey[dict[UUID, HabiticaPartyCoordinator]] = HassKey(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -37,6 +44,8 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry ) -> bool: """Set up habitica from a config entry.""" + party_added_by_this_entry: UUID | None = None + device_reg = dr.async_get(hass) session = async_get_clientsession( hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) @@ -54,11 +63,53 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + party = coordinator.data.user.party.id + if HABITICA_KEY not in hass.data: + hass.data[HABITICA_KEY] = {} + + if party is not None and party not in hass.data[HABITICA_KEY]: + party_coordinator = HabiticaPartyCoordinator(hass, config_entry, api) + await party_coordinator.async_config_entry_first_refresh() + + hass.data[HABITICA_KEY][party] = party_coordinator + party_added_by_this_entry = party + + @callback + def _party_update_listener() -> None: + """On party change, unload coordinator, remove device and reload.""" + nonlocal party, party_added_by_this_entry + party_updated = coordinator.data.user.party.id + + if ( + party is not None and (party not in hass.data[HABITICA_KEY]) + ) or party != party_updated: + if party_added_by_this_entry: + config_entry.async_create_task( + hass, shutdown_party_coordinator(hass, party_added_by_this_entry) + ) + party_added_by_this_entry = None + if party: + identifier = {(DOMAIN, f"{config_entry.unique_id}_{party!s}")} + if device := device_reg.async_get_device(identifiers=identifier): + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry.entry_id + ) + + hass.config_entries.async_schedule_reload(config_entry.entry_id) + + coordinator.async_add_listener(_party_update_listener) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True +async def shutdown_party_coordinator(hass: HomeAssistant, party_added: UUID) -> None: + """Handle party coordinator shutdown.""" + await hass.data[HABITICA_KEY][party_added].async_shutdown() + hass.data[HABITICA_KEY].pop(party_added) + + async def async_unload_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index c6f7ee0fb83..621c659a10c 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -6,18 +6,20 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from habiticalib import UserData +from habiticalib import ContentData, UserData from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import HABITICA_KEY from .const import ASSETS_URL -from .coordinator import HabiticaConfigEntry -from .entity import HabiticaBase +from .coordinator import HabiticaConfigEntry, HabiticaPartyCoordinator +from .entity import HabiticaBase, HabiticaPartyBase PARALLEL_UPDATES = 1 @@ -34,6 +36,7 @@ class HabiticaBinarySensor(StrEnum): """Habitica Entities.""" PENDING_QUEST = "pending_quest" + QUEST_RUNNING = "quest_running" def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None: @@ -62,10 +65,21 @@ async def async_setup_entry( coordinator = config_entry.runtime_data - async_add_entities( + entities: list[BinarySensorEntity] = [ HabiticaBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS - ) + ] + + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + entities.append( + HabiticaPartyBinarySensorEntity( + party_coordinator, + config_entry, + coordinator.content, + ) + ) + async_add_entities(entities) class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): @@ -86,3 +100,27 @@ class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): ): return f"{ASSETS_URL}{entity_picture}" return None + + +class HabiticaPartyBinarySensorEntity(HabiticaPartyBase, BinarySensorEntity): + """Representation of a Habitica party binary sensor.""" + + entity_description = BinarySensorEntityDescription( + key=HabiticaBinarySensor.QUEST_RUNNING, + translation_key=HabiticaBinarySensor.QUEST_RUNNING, + device_class=BinarySensorDeviceClass.RUNNING, + ) + + def __init__( + self, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + content: ContentData, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, config_entry, self.entity_description, content) + + @property + def is_on(self) -> bool | None: + """If the binary sensor is on.""" + return self.coordinator.data.quest.active diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index c57ba39fb6a..de8920deb77 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -7,15 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from aiohttp import ClientError -from habiticalib import ( - HabiticaClass, - HabiticaException, - NotAuthorizedError, - Skill, - TaskType, - TooManyRequestsError, -) +from habiticalib import Habitica, HabiticaClass, Skill, TaskType from homeassistant.components.button import ( DOMAIN as BUTTON_DOMAIN, @@ -23,16 +15,11 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ASSETS_URL, DOMAIN -from .coordinator import ( - HabiticaConfigEntry, - HabiticaData, - HabiticaDataUpdateCoordinator, -) +from .coordinator import HabiticaConfigEntry, HabiticaData from .entity import HabiticaBase PARALLEL_UPDATES = 1 @@ -42,7 +29,7 @@ PARALLEL_UPDATES = 1 class HabiticaButtonEntityDescription(ButtonEntityDescription): """Describes Habitica button entity.""" - press_fn: Callable[[HabiticaDataUpdateCoordinator], Any] + press_fn: Callable[[Habitica], Any] available_fn: Callable[[HabiticaData], bool] class_needed: HabiticaClass | None = None entity_picture: str | None = None @@ -73,13 +60,13 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.RUN_CRON, translation_key=HabiticaButtonEntity.RUN_CRON, - press_fn=lambda coordinator: coordinator.habitica.run_cron(), + press_fn=lambda habitica: habitica.run_cron(), available_fn=lambda data: data.user.needsCron is True, ), HabiticaButtonEntityDescription( key=HabiticaButtonEntity.BUY_HEALTH_POTION, translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION, - press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(), + press_fn=lambda habitica: habitica.buy_health_potion(), available_fn=( lambda data: (data.user.stats.gp or 0) >= 25 and (data.user.stats.hp or 0) < 50 @@ -89,7 +76,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS, translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS, - press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(), + press_fn=lambda habitica: habitica.allocate_stat_points(), available_fn=( lambda data: data.user.preferences.automaticAllocation is True and (data.user.stats.points or 0) > 0 @@ -98,7 +85,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.REVIVE, translation_key=HabiticaButtonEntity.REVIVE, - press_fn=lambda coordinator: coordinator.habitica.revive(), + press_fn=lambda habitica: habitica.revive(), available_fn=lambda data: data.user.stats.hp == 0, ), ) @@ -108,9 +95,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.MPHEAL, translation_key=HabiticaButtonEntity.MPHEAL, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.ETHEREAL_SURGE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 12 and (data.user.stats.mp or 0) >= 30 @@ -121,7 +106,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.EARTH, translation_key=HabiticaButtonEntity.EARTH, - press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE), + press_fn=lambda habitica: habitica.cast_skill(Skill.EARTHQUAKE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 35 @@ -132,9 +117,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.FROST, translation_key=HabiticaButtonEntity.FROST, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.CHILLING_FROST), # chilling frost can only be cast once per day (streaks buff is false) available_fn=( lambda data: (data.user.stats.lvl or 0) >= 14 @@ -147,9 +130,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.DEFENSIVE_STANCE, translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.DEFENSIVE_STANCE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 12 and (data.user.stats.mp or 0) >= 25 @@ -160,9 +141,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.VALOROUS_PRESENCE, translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.VALOROUS_PRESENCE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 20 @@ -173,9 +152,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.INTIMIDATE, translation_key=HabiticaButtonEntity.INTIMIDATE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.INTIMIDATING_GAZE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 14 and (data.user.stats.mp or 0) >= 15 @@ -186,11 +163,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.TOOLS_OF_TRADE, translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill( - Skill.TOOLS_OF_THE_TRADE - ) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.TOOLS_OF_THE_TRADE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 25 @@ -201,7 +174,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.STEALTH, translation_key=HabiticaButtonEntity.STEALTH, - press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH), + press_fn=lambda habitica: habitica.cast_skill(Skill.STEALTH), # Stealth buffs stack and it can only be cast if the amount of # buffs is smaller than the amount of unfinished dailies available_fn=( @@ -224,9 +197,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.HEAL, translation_key=HabiticaButtonEntity.HEAL, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.HEALING_LIGHT), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 11 and (data.user.stats.mp or 0) >= 15 @@ -238,11 +209,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.BRIGHTNESS, translation_key=HabiticaButtonEntity.BRIGHTNESS, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill( - Skill.SEARING_BRIGHTNESS - ) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.SEARING_BRIGHTNESS), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 12 and (data.user.stats.mp or 0) >= 15 @@ -253,9 +220,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.PROTECT_AURA, translation_key=HabiticaButtonEntity.PROTECT_AURA, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.PROTECTIVE_AURA), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 30 @@ -266,7 +231,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.HEAL_ALL, translation_key=HabiticaButtonEntity.HEAL_ALL, - press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING), + press_fn=lambda habitica: habitica.cast_skill(Skill.BLESSING), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 14 and (data.user.stats.mp or 0) >= 25 @@ -332,33 +297,9 @@ class HabiticaButton(HabiticaBase, ButtonEntity): async def async_press(self) -> None: """Handle the button press.""" - try: - await self.entity_description.press_fn(self.coordinator) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="service_call_unallowed", - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": e.error.message}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - await self.coordinator.async_request_refresh() + + await self.coordinator.execute(self.entity_description.press_fn) + await self.coordinator.async_request_refresh() @property def available(self) -> bool: diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 91a13bd7918..65d9be1bb7c 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -164,7 +164,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_API_USER: str(login.id), CONF_API_KEY: login.apiToken, - CONF_NAME: user.profile.name, # needed for api_call action CONF_URL: DEFAULT_URL, CONF_VERIFY_SSL: True, }, @@ -200,7 +199,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): data={ **user_input, CONF_URL: user_input.get(CONF_URL, DEFAULT_URL), - CONF_NAME: user.profile.name, # needed for api_call action }, ) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index f9874c711f0..d7cede1db03 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" SITE_DATA_URL = "https://habitica.com/user/settings/siteData" FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password" SIGN_UP_URL = "https://habitica.com/register" -HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png" +HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png" DOMAIN = "habitica" diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index d0eb60312b4..d9376820b16 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta @@ -13,6 +14,7 @@ from aiohttp import ClientError from habiticalib import ( Avatar, ContentData, + GroupData, Habitica, HabiticaException, NotAuthorizedError, @@ -23,12 +25,12 @@ from habiticalib import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, + ServiceValidationError, ) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -49,10 +51,11 @@ class HabiticaData: type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] -class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): - """Habitica Data Update Coordinator.""" +class HabiticaBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Habitica coordinator base class.""" config_entry: HabiticaConfigEntry + _update_interval: timedelta def __init__( self, hass: HomeAssistant, config_entry: HabiticaConfigEntry, habitica: Habitica @@ -63,7 +66,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=60), + update_interval=self._update_interval, request_refresh_debouncer=Debouncer( hass, _LOGGER, @@ -71,8 +74,40 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): immediate=False, ), ) + self.habitica = habitica - self.content: ContentData + + @abstractmethod + async def _update_data(self) -> _DataT: + """Fetch data.""" + + async def _async_update_data(self) -> _DataT: + """Fetch the latest party data.""" + + try: + return await self._update_data() + except TooManyRequestsError: + _LOGGER.debug("Rate limit exceeded, will try again later") + return self.data + except HabiticaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + +class HabiticaDataUpdateCoordinator(HabiticaBaseCoordinator[HabiticaData]): + """Habitica Data Update Coordinator.""" + + _update_interval = timedelta(seconds=30) + content: ContentData async def _async_setup(self) -> None: """Set up Habitica integration.""" @@ -106,50 +141,33 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): translation_placeholders={"reason": str(e)}, ) from e - if not self.config_entry.data.get(CONF_NAME): - self.hass.config_entries.async_update_entry( - self.config_entry, - data={**self.config_entry.data, CONF_NAME: user.data.profile.name}, - ) + async def _update_data(self) -> HabiticaData: + """Fetch the latest data.""" - async def _async_update_data(self) -> HabiticaData: - try: - user = (await self.habitica.get_user()).data - tasks = (await self.habitica.get_tasks()).data - completed_todos = ( - await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) - ).data - except TooManyRequestsError: - _LOGGER.debug("Rate limit exceeded, will try again later") - return self.data - except HabiticaException as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return HabiticaData(user=user, tasks=tasks + completed_todos) + user = (await self.habitica.get_user()).data + tasks = (await self.habitica.get_tasks()).data + completed_todos = ( + await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) + ).data - async def execute( - self, func: Callable[[HabiticaDataUpdateCoordinator], Any] - ) -> None: + return HabiticaData(user=user, tasks=tasks + completed_todos) + + async def execute(self, func: Callable[[Habitica], Any]) -> None: """Execute an API call.""" try: - await func(self) + await func(self.habitica) except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", translation_placeholders={"retry_after": str(e.retry_after)}, ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_call_unallowed", + ) from e except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -172,3 +190,13 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): await self.habitica.generate_avatar(fp=png, avatar=avatar, fmt="PNG") return png.getvalue() + + +class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]): + """Habitica Party Coordinator.""" + + _update_interval = timedelta(minutes=15) + + async def _update_data(self) -> GroupData: + """Fetch the latest party data.""" + return (await self.habitica.get_group()).data diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 692ea5e5ac1..fa227fec334 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -4,15 +4,20 @@ from __future__ import annotations from typing import TYPE_CHECKING +from habiticalib import ContentData from yarl import URL -from homeassistant.const import CONF_NAME, CONF_URL +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 DOMAIN, MANUFACTURER, NAME -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): @@ -37,7 +42,7 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): entry_type=DeviceEntryType.SERVICE, manufacturer=MANUFACTURER, model=NAME, - name=coordinator.config_entry.data[CONF_NAME], + name=coordinator.data.user.profile.name, configuration_url=( URL(coordinator.config_entry.data[CONF_URL]) / "profile" @@ -45,3 +50,33 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): ), identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, ) + + +class HabiticaPartyBase(CoordinatorEntity[HabiticaPartyCoordinator]): + """Base Habitica entity representing a party.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + entity_description: EntityDescription, + content: ContentData, + ) -> None: + """Initialize a Habitica party entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert config_entry.unique_id + unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}" + self.entity_description = entity_description + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=NAME, + name=coordinator.data.summary, + identifiers={(DOMAIN, unique_id)}, + via_device=(DOMAIN, config_entry.unique_id), + ) + self.content = content diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index d241d3855d6..0b5d4aaa682 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -82,9 +82,6 @@ "0": "mdi:skull-outline" } }, - "health_max": { - "default": "mdi:heart" - }, "mana": { "default": "mdi:flask", "state": { @@ -121,12 +118,6 @@ "rogue": "mdi:ninja" } }, - "habits": { - "default": "mdi:contrast-box" - }, - "rewards": { - "default": "mdi:treasure-chest" - }, "strength": { "default": "mdi:arm-flex-outline" }, @@ -165,6 +156,24 @@ }, "pending_quest_items": { "default": "mdi:sack" + }, + "group_leader": { + "default": "mdi:shield-crown" + }, + "quest": { + "default": "mdi:script-text-outline" + }, + "boss": { + "default": "mdi:emoticon-devil" + }, + "boss_hp": { + "default": "mdi:heart" + }, + "boss_hp_remaining": { + "default": "mdi:heart" + }, + "collected_items": { + "default": "mdi:sack" } }, "switch": { @@ -181,6 +190,9 @@ "state": { "on": "mdi:script-text-outline" } + }, + "quest_running": { + "default": "mdi:script-text-play" } } }, diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index 1669f124bc7..f064074ea0a 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -4,15 +4,21 @@ from __future__ import annotations from enum import StrEnum -from habiticalib import Avatar, extract_avatar +from habiticalib import Avatar, ContentData, extract_avatar -from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator -from .entity import HabiticaBase +from . import HABITICA_KEY +from .const import ASSETS_URL +from .coordinator import ( + HabiticaConfigEntry, + HabiticaDataUpdateCoordinator, + HabiticaPartyCoordinator, +) +from .entity import HabiticaBase, HabiticaPartyBase PARALLEL_UPDATES = 1 @@ -21,6 +27,7 @@ class HabiticaImageEntity(StrEnum): """Image entities.""" AVATAR = "avatar" + QUEST_IMAGE = "quest_image" async def async_setup_entry( @@ -31,8 +38,17 @@ async def async_setup_entry( """Set up the habitica image platform.""" coordinator = config_entry.runtime_data + entities: list[ImageEntity] = [HabiticaImage(hass, coordinator)] - async_add_entities([HabiticaImage(hass, coordinator)]) + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + entities.append( + HabiticaPartyImage( + hass, party_coordinator, config_entry, coordinator.content + ) + ) + + async_add_entities(entities) class HabiticaImage(HabiticaBase, ImageEntity): @@ -72,3 +88,58 @@ class HabiticaImage(HabiticaBase, ImageEntity): if not self._cache and self._avatar: self._cache = await self.coordinator.generate_avatar(self._avatar) return self._cache + + +class HabiticaPartyImage(HabiticaPartyBase, ImageEntity): + """A Habitica image entity of a party.""" + + entity_description = ImageEntityDescription( + key=HabiticaImageEntity.QUEST_IMAGE, + translation_key=HabiticaImageEntity.QUEST_IMAGE, + ) + _attr_content_type = "image/png" + + def __init__( + self, + hass: HomeAssistant, + coordinator: HabiticaPartyCoordinator, + config_entry: HabiticaConfigEntry, + content: ContentData, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, config_entry, self.entity_description, content) + ImageEntity.__init__(self, hass) + + self._attr_image_url = self.image_url + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + if self.image_url != self._attr_image_url: + self._attr_image_url = self.image_url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + + super()._handle_coordinator_update() + + @property + def image_url(self) -> str | None: + """Return URL of image.""" + return ( + f"{ASSETS_URL}quest_{key}.png" + if (key := self.coordinator.data.quest.key) + else None + ) + + async def _async_load_image_from_url(self, url: str) -> Image | None: + """Load an image by url. + + AWS sometimes returns 'application/octet-stream' as content-type + """ + if response := await self._fetch_url(url): + return Image( + content=response.content, + content_type=self._attr_content_type, + ) + return None diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 8b03e5efe01..e0c58383bcc 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.0"] + "requirements": ["habiticalib==0.4.2"] } diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 1752e67cf46..c5131b81a4d 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -72,7 +72,7 @@ rules: comment: Used to inform of deprecated entities and actions. stale-devices: status: done - comment: Not applicable. Only one device per config entry. Removed together with the config entry. + comment: Party device is remove if stale. # Platinum async-dependency: done diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 5b64d0d8119..7a84d589bfb 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -2,50 +2,37 @@ from __future__ import annotations -from collections.abc import Callable, Mapping -from dataclasses import asdict, dataclass +from collections.abc import Callable +from dataclasses import dataclass from enum import StrEnum import logging from typing import Any -from habiticalib import ( - ContentData, - HabiticaClass, - TaskData, - TaskType, - UserData, - deserialize_task, - ha, -) +from habiticalib import ContentData, GroupData, HabiticaClass, TaskData, UserData, ha -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) 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 homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .const import ASSETS_URL, DOMAIN -from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator -from .entity import HabiticaBase +from . import HABITICA_KEY +from .const import ASSETS_URL +from .coordinator import HabiticaConfigEntry +from .entity import HabiticaBase, HabiticaPartyBase from .util import ( + collected_quest_items, get_attribute_points, get_attributes_total, inventory_list, pending_damage, pending_quest_items, + quest_attributes, + quest_boss, ) _LOGGER = logging.getLogger(__name__) @@ -72,6 +59,17 @@ class HabiticaSensorEntityDescription(SensorEntityDescription): entity_picture: str | None = None +@dataclass(kw_only=True, frozen=True) +class HabiticaPartySensorEntityDescription(SensorEntityDescription): + """Habitica Party Sensor Description.""" + + value_fn: Callable[[GroupData, ContentData], StateType] + entity_picture: Callable[[GroupData], str | None] | str | None = None + attributes_fn: Callable[[GroupData, ContentData], dict[str, Any] | None] | None = ( + None + ) + + @dataclass(kw_only=True, frozen=True) class HabiticaTaskSensorEntityDescription(SensorEntityDescription): """Habitica Task Sensor Description.""" @@ -84,7 +82,6 @@ class HabiticaSensorEntity(StrEnum): DISPLAY_NAME = "display_name" HEALTH = "health" - HEALTH_MAX = "health_max" MANA = "mana" MANA_MAX = "mana_max" EXPERIENCE = "experience" @@ -107,6 +104,13 @@ class HabiticaSensorEntity(StrEnum): QUEST_SCROLLS = "quest_scrolls" PENDING_DAMAGE = "pending_damage" PENDING_QUEST_ITEMS = "pending_quest_items" + MEMBER_COUNT = "member_count" + GROUP_LEADER = "group_leader" + QUEST = "quest" + BOSS = "boss" + BOSS_HP = "boss_hp" + BOSS_HP_REMAINING = "boss_hp_remaining" + COLLECTED_ITEMS = "collected_items" SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( @@ -136,12 +140,6 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( value_fn=lambda user, _: user.stats.hp, entity_picture=ha.HP, ), - HabiticaSensorEntityDescription( - key=HabiticaSensorEntity.HEALTH_MAX, - translation_key=HabiticaSensorEntity.HEALTH_MAX, - entity_registry_enabled_default=False, - value_fn=lambda user, _: 50, - ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.MANA, translation_key=HabiticaSensorEntity.MANA, @@ -286,57 +284,67 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( ) -TASKS_MAP_ID = "id" -TASKS_MAP = { - "repeat": "repeat", - "challenge": "challenge", - "group": "group", - "frequency": "frequency", - "every_x": "everyX", - "streak": "streak", - "up": "up", - "down": "down", - "counter_up": "counterUp", - "counter_down": "counterDown", - "next_due": "nextDue", - "yester_daily": "yesterDaily", - "completed": "completed", - "collapse_checklist": "collapseChecklist", - "type": "Type", - "notes": "notes", - "tags": "tags", - "value": "value", - "priority": "priority", - "start_date": "startDate", - "days_of_month": "daysOfMonth", - "weeks_of_month": "weeksOfMonth", - "created_at": "createdAt", - "text": "text", - "is_due": "isDue", -} - - -TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = ( - HabiticaTaskSensorEntityDescription( - key=HabiticaSensorEntity.HABITS, - translation_key=HabiticaSensorEntity.HABITS, - value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT], +SENSOR_DESCRIPTIONS_PARTY: tuple[HabiticaPartySensorEntityDescription, ...] = ( + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.MEMBER_COUNT, + translation_key=HabiticaSensorEntity.MEMBER_COUNT, + value_fn=lambda party, _: party.memberCount, + entity_picture=ha.PARTY, ), - HabiticaTaskSensorEntityDescription( - key=HabiticaSensorEntity.REWARDS, - translation_key=HabiticaSensorEntity.REWARDS, - value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD], + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.GROUP_LEADER, + translation_key=HabiticaSensorEntity.GROUP_LEADER, + value_fn=lambda party, _: party.leader.profile.name, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.QUEST, + translation_key=HabiticaSensorEntity.QUEST, + value_fn=lambda p, c: c.quests[p.quest.key].text if p.quest.key else None, + attributes_fn=quest_attributes, + entity_picture=( + lambda party: f"inventory_quest_scroll_{party.quest.key}.png" + if party.quest.key + else None + ), + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS, + translation_key=HabiticaSensorEntity.BOSS, + value_fn=lambda p, c: boss.name if (boss := quest_boss(p, c)) else None, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS_HP, + translation_key=HabiticaSensorEntity.BOSS_HP, + value_fn=lambda p, c: boss.hp if (boss := quest_boss(p, c)) else None, + entity_picture=ha.HP, + suggested_display_precision=0, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.BOSS_HP_REMAINING, + translation_key=HabiticaSensorEntity.BOSS_HP_REMAINING, + value_fn=lambda p, _: p.quest.progress.hp, + entity_picture=ha.HP, + suggested_display_precision=2, + ), + HabiticaPartySensorEntityDescription( + key=HabiticaSensorEntity.COLLECTED_ITEMS, + translation_key=HabiticaSensorEntity.COLLECTED_ITEMS, + value_fn=( + lambda p, _: sum(n for n in p.quest.progress.collect.values()) + if p.quest.progress.collect + else None + ), + attributes_fn=collected_quest_items, + entity_picture=( + lambda p: f"quest_{p.quest.key}_{k}.png" + if p.quest.progress.collect + and (k := next(iter(p.quest.progress.collect), None)) + else None + ), ), ) -def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: - """Get list of related automations and scripts.""" - used_in = automations_with_entity(hass, entity_id) - used_in += scripts_with_entity(hass, entity_id) - return used_in - - async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, @@ -345,59 +353,22 @@ async def async_setup_entry( """Set up the habitica sensors.""" coordinator = config_entry.runtime_data - ent_reg = er.async_get(hass) - entities: list[SensorEntity] = [] - description: SensorEntityDescription - def add_deprecated_entity( - description: SensorEntityDescription, - entity_cls: Callable[ - [HabiticaDataUpdateCoordinator, SensorEntityDescription], SensorEntity - ], - ) -> None: - """Add deprecated entities.""" - if entity_id := ent_reg.async_get_entity_id( - SENSOR_DOMAIN, - DOMAIN, - f"{config_entry.unique_id}_{description.key}", - ): - 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_{description.key}", - ) - elif entity_entry: - entities.append(entity_cls(coordinator, description)) - if entity_used_in(hass, entity_id): - async_create_issue( - hass, - DOMAIN, - f"deprecated_entity_{description.key}", - breaks_in_ha_version="2025.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "name": str( - entity_entry.name or entity_entry.original_name - ), - "entity": entity_id, - }, - ) + async_add_entities( + HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS + ) - for description in SENSOR_DESCRIPTIONS: - if description.key is HabiticaSensorEntity.HEALTH_MAX: - add_deprecated_entity(description, HabiticaSensor) - else: - entities.append(HabiticaSensor(coordinator, description)) - - for description in TASK_SENSOR_DESCRIPTION: - add_deprecated_entity(description, HabiticaTaskSensor) - - async_add_entities(entities, True) + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + async_add_entities( + HabiticaPartySensor( + party_coordinator, + config_entry, + description, + coordinator.content, + ) + for description in SENSOR_DESCRIPTIONS_PARTY + ) class HabiticaSensor(HabiticaBase, SensorEntity): @@ -443,29 +414,37 @@ class HabiticaSensor(HabiticaBase, SensorEntity): return None -class HabiticaTaskSensor(HabiticaBase, SensorEntity): - """A Habitica task sensor.""" +class HabiticaPartySensor(HabiticaPartyBase, SensorEntity): + """Habitica party sensor.""" - entity_description: HabiticaTaskSensorEntityDescription + entity_description: HabiticaPartySensorEntityDescription @property def native_value(self) -> StateType: """Return the state of the device.""" - return len(self.entity_description.value_fn(self.coordinator.data.tasks)) + return self.entity_description.value_fn(self.coordinator.data, self.content) @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the state attributes of all user tasks.""" - attrs = {} + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + pic = self.entity_description.entity_picture - # Map tasks to TASKS_MAP - for task_data in self.entity_description.value_fn(self.coordinator.data.tasks): - received_task = deserialize_task(asdict(task_data)) - task_id = received_task[TASKS_MAP_ID] - task = {} - for map_key, map_value in TASKS_MAP.items(): - if value := received_task.get(map_value): - task[map_key] = value - attrs[str(task_id)] = task - return attrs + entity_picture = ( + pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data) + ) + + return ( + None + if not entity_picture + else entity_picture + if entity_picture.startswith("data:image") + else f"{ASSETS_URL}{entity_picture}" + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + if func := self.entity_description.attributes_fn: + return func(self.coordinator.data, self.content) + return None diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 22bc79555e8..1d62b242149 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -4,10 +4,10 @@ "dailies": "Dailies", "config_entry_name": "Select character", "task_name": "Task name", - "unit_tasks": "tasks", "unit_health_points": "HP", "unit_mana_points": "MP", "unit_experience_points": "XP", + "unit_items": "items", "config_entry_description": "Select the Habitica account to update a task.", "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", @@ -64,7 +64,8 @@ "repeat_weekly_options_name": "Weekly repeat days", "repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.", "repeat_monthly_options_name": "Monthly repeat day", - "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." + "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly.", + "quest_name": "Quest" }, "config": { "abort": { @@ -174,6 +175,9 @@ "binary_sensor": { "pending_quest": { "name": "Pending quest invitation" + }, + "quest_running": { + "name": "Quest status" } }, "button": { @@ -252,6 +256,9 @@ "image": { "avatar": { "name": "Avatar" + }, + "quest_image": { + "name": "[%key:component::habitica::common::quest_name%]" } }, "sensor": { @@ -276,10 +283,6 @@ "name": "Health", "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" }, - "health_max": { - "name": "Max. health", - "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" - }, "mana": { "name": "Mana", "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]" @@ -319,14 +322,6 @@ "rogue": "Rogue" } }, - "habits": { - "name": "Habits", - "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]" - }, - "rewards": { - "name": "Rewards", - "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]" - }, "strength": { "name": "Strength", "state_attributes": { @@ -433,7 +428,37 @@ }, "pending_quest_items": { "name": "Pending quest items", - "unit_of_measurement": "items" + "unit_of_measurement": "[%key:component::habitica::common::unit_items%]" + }, + "member_count": { + "name": "Member count", + "unit_of_measurement": "members" + }, + "group_leader": { + "name": "Group leader" + }, + "quest": { + "name": "[%key:component::habitica::common::quest_name%]", + "state_attributes": { + "quest_details": { + "name": "Quest details" + } + } + }, + "boss": { + "name": "Quest boss" + }, + "boss_hp": { + "name": "Boss health", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" + }, + "boss_hp_remaining": { + "name": "Boss health remaining", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" + }, + "collected_items": { + "name": "Collected quest items", + "unit_of_measurement": "[%key:component::habitica::common::unit_items%]" } }, "switch": { diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index fb98460f7e5..826cd341bba 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any +from habiticalib import Habitica + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -15,11 +17,7 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ( - HabiticaConfigEntry, - HabiticaData, - HabiticaDataUpdateCoordinator, -) +from .coordinator import HabiticaConfigEntry, HabiticaData from .entity import HabiticaBase PARALLEL_UPDATES = 1 @@ -29,8 +27,8 @@ PARALLEL_UPDATES = 1 class HabiticaSwitchEntityDescription(SwitchEntityDescription): """Describes Habitica switch entity.""" - turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any] - turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any] + turn_on_fn: Callable[[Habitica], Any] + turn_off_fn: Callable[[Habitica], Any] is_on_fn: Callable[[HabiticaData], bool | None] @@ -45,8 +43,8 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = ( key=HabiticaSwitchEntity.SLEEP, translation_key=HabiticaSwitchEntity.SLEEP, device_class=SwitchDeviceClass.SWITCH, - turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), - turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), + turn_on_fn=lambda habitica: habitica.toggle_sleep(), + turn_off_fn=lambda habitica: habitica.toggle_sleep(), is_on_fn=lambda data: data.user.preferences.sleep, ), ) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 9ef0b8cbadd..8c2148192a3 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import asdict, fields import datetime from math import floor -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal from dateutil.rrule import ( DAILY, @@ -21,7 +21,7 @@ from dateutil.rrule import ( YEARLY, rrule, ) -from habiticalib import ContentData, Frequency, TaskData, UserData +from habiticalib import ContentData, Frequency, GroupData, QuestBoss, TaskData, UserData from homeassistant.util import dt as dt_util @@ -56,7 +56,12 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N return dt_util.as_local(task.nextDue[0]).date() -FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} +FREQUENCY_MAP: dict[str, Literal[0, 1, 2, 3]] = { + "daily": DAILY, + "weekly": WEEKLY, + "monthly": MONTHLY, + "yearly": YEARLY, +} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} @@ -95,21 +100,16 @@ def get_recurrence_rule(recurrence: rrule) -> str: 'DTSTART:YYYYMMDDTHHMMSS\nRRULE:FREQ=YEARLY;INTERVAL=2' - Parameters - ---------- - recurrence : rrule - An RRULE object. + Args: + recurrence: An RRULE object. - Returns - ------- - str + Returns: The recurrence rule portion of the RRULE string, starting with 'FREQ='. - Example - ------- - >>> rule = get_recurrence_rule(task) - >>> print(rule) - 'FREQ=YEARLY;INTERVAL=2' + Example: + >>> rule = get_recurrence_rule(task) + >>> print(rule) + 'FREQ=YEARLY;INTERVAL=2' """ return str(recurrence).split("RRULE:")[1] @@ -184,3 +184,32 @@ def pending_damage(user: UserData, content: ContentData) -> float | None: and content.quests[user.party.quest.key].boss is not None else None ) + + +def quest_attributes(party: GroupData, content: ContentData) -> dict[str, Any]: + """Quest description.""" + return { + "quest_details": content.quests[party.quest.key].notes + if party.quest.key + else None, + "quest_participants": f"{sum(x is True for x in party.quest.members.values())} / {party.memberCount}", + } + + +def quest_boss(party: GroupData, content: ContentData) -> QuestBoss | None: + """Quest boss.""" + + return content.quests[party.quest.key].boss if party.quest.key else None + + +def collected_quest_items(party: GroupData, content: ContentData) -> dict[str, Any]: + """List collected quest items.""" + + return ( + { + collect[k].text: f"{v} / {collect[k].count}" + for k, v in party.quest.progress.collect.items() + } + if party.quest.key and (collect := content.quests[party.quest.key].collect) + else {} + ) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 7f7bf077e21..1e9a14be1f2 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -48,13 +48,13 @@ from homeassistant.components.backup import ( RestoreBackupStage, RestoreBackupState, WrittenBackup, + async_get_manager as async_get_backup_manager, suggested_filename as suggested_backup_filename, suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -839,7 +839,7 @@ async def backup_addon_before_update( async def backup_core_before_update(hass: HomeAssistant) -> None: """Prepare for updating core.""" - backup_manager = await async_get_backup_manager(hass) + backup_manager = async_get_backup_manager(hass) client = get_supervisor_client(hass) try: diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index e673c3a70e9..e1f96b76bcb 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -11,6 +11,7 @@ from urllib.parse import quote import aiohttp from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web +from aiohttp.helpers import must_be_empty_body from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from yarl import URL @@ -184,13 +185,16 @@ class HassIOIngress(HomeAssistantView): content_type = "application/octet-stream" # Simple request - if result.status in (204, 304) or ( + if (empty_body := must_be_empty_body(result.method, result.status)) or ( content_length is not UNDEFINED and (content_length_int := int(content_length)) <= MAX_SIMPLE_RESPONSE_SIZE ): # Return Response - body = await result.read() + if empty_body: + body = None + else: + body = await result.read() simple_response = web.Response( headers=headers, status=result.status, @@ -235,13 +239,13 @@ def _forwarded_for_header(forward_for: str | None, peer_name: str) -> str: return f"{forward_for}, {connected_ip!s}" if forward_for else f"{connected_ip!s}" -def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, str]: +def _init_header(request: web.Request, token: str) -> CIMultiDict: """Create initial header.""" - headers = { - name: value + headers = CIMultiDict( + (name, value) for name, value in request.headers.items() if name not in INIT_HEADERS_FILTER - } + ) # Ingress information headers[X_HASS_SOURCE] = "core.ingress" headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" @@ -269,13 +273,13 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st return headers -def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: +def _response_header(response: aiohttp.ClientResponse) -> CIMultiDict: """Create response header.""" - return { - name: value + return CIMultiDict( + (name, value) for name, value in response.headers.items() if name not in RESPONSE_HEADERS_FILTER - } + ) def _is_websocket(request: web.Request) -> bool: diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 16697659077..22406e86ba1 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -61,18 +61,19 @@ PLACEHOLDER_KEY_REASON = "reason" UNSUPPORTED_REASONS = { "apparmor", + "cgroup_version", "connectivity_check", "content_trust", "dbus", "dns_server", "docker_configuration", "docker_version", - "cgroup_version", "job_conditions", "lxc", "network_manager", "os", "os_agent", + "os_version", "restart_policy", "software", "source_mods", @@ -80,15 +81,18 @@ UNSUPPORTED_REASONS = { "systemd", "systemd_journal", "systemd_resolved", + "virtualization_image", } # Some unsupported reasons also mark the system as unhealthy. If the unsupported reason # provides no additional information beyond the unhealthy one then skip that repair. UNSUPPORTED_SKIP_REPAIR = {"privileged"} UNHEALTHY_REASONS = { "docker", - "supervisor", - "setup", + "duplicate_os_installation", + "oserror_bad_message", "privileged", + "setup", + "supervisor", "untrusted", } @@ -101,6 +105,7 @@ ISSUE_KEYS_FOR_REPAIRS = { ISSUE_KEY_SYSTEM_DOCKER_CONFIG, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + "issue_system_disk_lifetime", } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index a2af6fb217c..34a8f466158 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.1"], + "requirements": ["aiohasupervisor==0.3.2b0"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index e34aa020c5a..393fe480057 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -9,6 +9,7 @@ "healthy": "Healthy", "host_os": "Host operating system", "installed_addons": "Installed add-ons", + "nameservers": "Nameservers", "supervisor_api": "Supervisor API", "supervisor_version": "Supervisor version", "supported": "Supported", @@ -114,37 +115,49 @@ } } }, + "issue_system_disk_lifetime": { + "title": "Disk lifetime exceeding 90%", + "description": "The data disk has exceeded 90% of its expected lifespan. The disk may soon malfunction which can lead to data loss. You should replace it soon and migrate your data." + }, "unhealthy": { "title": "Unhealthy system - {reason}", - "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more." }, "unhealthy_docker": { "title": "Unhealthy system - Docker misconfigured", - "description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because Docker is configured incorrectly. For troubleshooting information, select Learn more." }, - "unhealthy_supervisor": { - "title": "Unhealthy system - Supervisor update failed", - "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this." + "unhealthy_duplicate_os_installation": { + "description": "System is currently unhealthy because it has detected multiple Home Assistant OS installations. For troubleshooting information, select Learn more.", + "title": "Unhealthy system - Duplicate Home Assistant OS installation" }, - "unhealthy_setup": { - "title": "Unhealthy system - Setup failed", - "description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this." + "unhealthy_oserror_bad_message": { + "description": "System is currently unhealthy because the operating system has reported an OS error: Bad message. For troubleshooting information, select Learn more.", + "title": "Unhealthy system - Operating System error: Bad message" }, "unhealthy_privileged": { "title": "Unhealthy system - Not privileged", - "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. For troubleshooting information, select Learn more." + }, + "unhealthy_setup": { + "title": "Unhealthy system - Setup failed", + "description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, For troubleshooting information, select Learn more." + }, + "unhealthy_supervisor": { + "title": "Unhealthy system - Supervisor update failed", + "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. For troubleshooting information, select Learn more." }, "unhealthy_untrusted": { "title": "Unhealthy system - Untrusted code", - "description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because it has detected untrusted code or images in use. For troubleshooting information, select Learn more." }, "unsupported": { "title": "Unsupported system - {reason}", - "description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this." + "description": "System is unsupported due to {reason}. For troubleshooting information, select Learn more." }, "unsupported_apparmor": { "title": "Unsupported system - AppArmor issues", - "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this." + "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. For troubleshooting information, select Learn more." }, "unsupported_cgroup_version": { "title": "Unsupported system - CGroup version", @@ -152,23 +165,23 @@ }, "unsupported_connectivity_check": { "title": "Unsupported system - Connectivity check disabled", - "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more." }, "unsupported_content_trust": { "title": "Unsupported system - Content-trust check disabled", - "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this." + "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." }, "unsupported_dbus": { "title": "Unsupported system - D-Bus issues", - "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this." + "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." }, "unsupported_dns_server": { "title": "Unsupported system - DNS server issues", - "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this." + "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. For troubleshooting information, select Learn more." }, "unsupported_docker_configuration": { "title": "Unsupported system - Docker misconfigured", - "description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this." + "description": "System is unsupported because the Docker daemon is running in an unexpected way. For troubleshooting information, select Learn more." }, "unsupported_docker_version": { "title": "Unsupported system - Docker version", @@ -176,15 +189,15 @@ }, "unsupported_job_conditions": { "title": "Unsupported system - Protections disabled", - "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this." + "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. For troubleshooting information, select Learn more." }, "unsupported_lxc": { "title": "Unsupported system - LXC detected", - "description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this." + "description": "System is unsupported because it is being run in an LXC virtual machine. For troubleshooting information, select Learn more." }, "unsupported_network_manager": { "title": "Unsupported system - Network Manager issues", - "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_os": { "title": "Unsupported system - Operating System", @@ -192,39 +205,43 @@ }, "unsupported_os_agent": { "title": "Unsupported system - OS-Agent issues", - "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_restart_policy": { "title": "Unsupported system - Container restart policy", - "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this." + "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. For troubleshooting information, select Learn more." }, "unsupported_software": { "title": "Unsupported system - Unsupported software", - "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this." + "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more." }, "unsupported_source_mods": { "title": "Unsupported system - Supervisor source modifications", - "description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this." + "description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more." }, "unsupported_supervisor_version": { "title": "Unsupported system - Supervisor version", - "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this." + "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." }, "unsupported_systemd": { "title": "Unsupported system - Systemd issues", - "description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_systemd_journal": { "title": "Unsupported system - Systemd Journal issues", - "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", - "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", - "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." + "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. For troubleshooting information, select Learn more." + }, + "unsupported_os_version": { + "title": "Unsupported system - Home Assistant OS version", + "description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more." } }, "entity": { @@ -238,7 +255,7 @@ "name": "OS Agent version" }, "apparmor_version": { - "name": "Apparmor version" + "name": "AppArmor version" }, "cpu_percent": { "name": "CPU percent" diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index bc8da2a2a92..0a7e9b51e97 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -54,6 +54,15 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: "error": "Unsupported", } + nameservers = set() + for interface in network_info.get("interfaces", []): + if not interface.get("primary"): + continue + if ipv4 := interface.get("ipv4"): + nameservers.update(ipv4.get("nameservers", [])) + if ipv6 := interface.get("ipv6"): + nameservers.update(ipv6.get("nameservers", [])) + information = { "host_os": host_info.get("operating_system"), "update_channel": info.get("channel"), @@ -62,6 +71,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: "docker_version": info.get("docker"), "disk_total": f"{host_info.get('disk_total')} GB", "disk_used": f"{host_info.get('disk_used')} GB", + "nameservers": ", ".join(nameservers), "healthy": healthy, "supported": supported, "host_connectivity": network_info.get("host_internet"), diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py index 66a819f1e8d..121238df9fe 100644 --- a/homeassistant/components/hddtemp/__init__.py +++ b/homeassistant/components/hddtemp/__init__.py @@ -1,3 +1 @@ """The hddtemp component.""" - -DOMAIN = "hddtemp" diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 192ddffd330..4d9bbeb9516 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -22,14 +22,11 @@ from homeassistant.const import ( CONF_PORT, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN - _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" @@ -59,21 +56,6 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the HDDTemp sensor.""" - create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - breaks_in_ha_version="2025.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_system_packages_yaml_integration", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "hddtemp", - }, - ) - name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 4df1a2fa0e1..54510540f2a 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from . import services from .const import DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator +from .services import async_setup_services PLATFORMS = [Platform.MEDIA_PLAYER] @@ -22,7 +22,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" - services.register(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index e2d3e2522dc..b6cda10dcb7 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, ENTRY_TITLE from .coordinator import HeosConfigEntry @@ -142,51 +143,16 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert discovery_info.ssdp_location - entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovery is ignored or host is part of the current system - if entry and ( - entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) - ): - return self.async_abort(reason="single_instance_allowed") + return await self._async_handle_discovered(hostname) - # Connect to discovered host and get system information - heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) - try: - await heos.connect() - system_info = await heos.get_system_info() - except HeosError as error: - _LOGGER.debug( - "Failed to retrieve system information from discovered HEOS device %s", - hostname, - exc_info=error, - ) - return self.async_abort(reason="cannot_connect") - finally: - await heos.disconnect() - - # Select the preferred host, if available - if system_info.preferred_hosts: - hostname = system_info.preferred_hosts[0].ip_address - - # Move to confirmation when not configured - if entry is None: - self._discovered_host = hostname - return await self.async_step_confirm_discovery() - - # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload - if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: - _LOGGER.debug( - "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname - ) - return self.async_update_reload_and_abort( - entry, - data_updates={CONF_HOST: hostname}, - reason="reconfigure_successful", - ) - return self.async_abort(reason="single_instance_allowed") + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + return await self._async_handle_discovered(discovery_info.host) async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -267,6 +233,50 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): ), ) + async def _async_handle_discovered(self, hostname: str) -> ConfigFlowResult: + entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): + return self.async_abort(reason="single_instance_allowed") + + # Connect to discovered host and get system information + heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) + try: + await heos.connect() + system_info = await heos.get_system_info() + except HeosError as error: + _LOGGER.debug( + "Failed to retrieve system information from discovered HEOS device %s", + hostname, + exc_info=error, + ) + return self.async_abort(reason="cannot_connect") + finally: + await heos.disconnect() + + # Select the preferred host, if available + if system_info.preferred_hosts and system_info.preferred_hosts[0].ip_address: + hostname = system_info.preferred_hosts[0].ip_address + + # Move to confirmation when not configured + if entry is None: + self._discovered_host = hostname + return await self.async_step_confirm_discovery() + + # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload + if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: + _LOGGER.debug( + "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: hostname}, + reason="reconfigure_successful", + ) + return self.async_abort(reason="single_instance_allowed") + class HeosOptionsFlowHandler(OptionsFlow): """Define HEOS options flow.""" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 8a88913456d..99cedf56f1f 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -13,5 +13,6 @@ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" } - ] + ], + "zeroconf": ["_heos-audio._tcp.local."] } diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 86c6f6d0533..e42e2bf27a2 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -44,7 +44,8 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema( HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register HEOS services.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 5393dfa5050..741a9a1058c 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations +import logging + from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from .const import TRAVEL_MODE_PUBLIC +from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC from .coordinator import ( HereConfigEntry, HERERoutingDataUpdateCoordinator, @@ -15,6 +17,8 @@ from .coordinator import ( PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool: """Set up HERE Travel Time from a config entry.""" @@ -43,3 +47,28 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: HereConfigEntry +) -> bool: + """Migrate an old config entry.""" + + if config_entry.version == 1 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_TRAFFIC_MODE] = True + + hass.config_entries.async_update_entry( + config_entry, options=options, version=1, 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/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 6425b5ffbed..5ff0a68bc9a 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, EntitySelector, LocationSelector, TimeSelector, @@ -50,6 +51,7 @@ from .const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_NAME, DOMAIN, ROUTE_MODE_FASTEST, @@ -65,6 +67,7 @@ DEFAULT_OPTIONS = { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: None, CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, } @@ -102,6 +105,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for HERE Travel Time.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Init Config Flow.""" @@ -307,7 +311,9 @@ class HERETravelTimeOptionsFlow(OptionsFlow): """Manage the HERE Travel Time options.""" if user_input is not None: self._config = user_input - return await self.async_step_time_menu() + if self._config[CONF_TRAFFIC_MODE]: + return await self.async_step_time_menu() + return self.async_create_entry(title="", data=self._config) schema = self.add_suggested_values_to_schema( vol.Schema( @@ -318,12 +324,21 @@ class HERETravelTimeOptionsFlow(OptionsFlow): CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] ), ): vol.In(ROUTE_MODES), + vol.Optional( + CONF_TRAFFIC_MODE, + default=self.config_entry.options.get( + CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE] + ), + ): BooleanSelector(), } ), { CONF_ROUTE_MODE: self.config_entry.options.get( CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] ), + CONF_TRAFFIC_MODE: self.config_entry.options.get( + CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE] + ), }, ) diff --git a/homeassistant/components/here_travel_time/const.py b/homeassistant/components/here_travel_time/const.py index 785070cd3b1..cc208d95abe 100644 --- a/homeassistant/components/here_travel_time/const.py +++ b/homeassistant/components/here_travel_time/const.py @@ -19,6 +19,7 @@ CONF_ARRIVAL = "arrival" CONF_DEPARTURE = "departure" CONF_ARRIVAL_TIME = "arrival_time" CONF_DEPARTURE_TIME = "departure_time" +CONF_TRAFFIC_MODE = "traffic_mode" DEFAULT_NAME = "HERE Travel Time" diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index d8c698554c9..0e447770ca9 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -13,6 +13,7 @@ from here_routing import ( Return, RoutingMode, Spans, + TrafficMode, TransportMode, ) import here_transit @@ -44,6 +45,7 @@ from .const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST, @@ -87,7 +89,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] _LOGGER.debug( ( "Requesting route for origin: %s, destination: %s, route_mode: %s," - " mode: %s, arrival: %s, departure: %s" + " mode: %s, arrival: %s, departure: %s, traffic_mode: %s" ), params.origin, params.destination, @@ -95,6 +97,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] TransportMode(params.travel_mode), params.arrival, params.departure, + params.traffic_mode, ) try: @@ -109,6 +112,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] routing_mode=params.route_mode, arrival_time=params.arrival, departure_time=params.departure, + traffic_mode=params.traffic_mode, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -350,6 +354,11 @@ def prepare_parameters( if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST else RoutingMode.SHORT ) + traffic_mode = ( + TrafficMode.DISABLED + if config_entry.options[CONF_TRAFFIC_MODE] is False + else TrafficMode.DEFAULT + ) return HERETravelTimeAPIParams( destination=destination, @@ -358,6 +367,7 @@ def prepare_parameters( route_mode=route_mode, arrival=arrival, departure=departure, + traffic_mode=traffic_mode, ) diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index a0534d2ff01..deb886f6805 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from typing import TypedDict -from here_routing import RoutingMode +from here_routing import RoutingMode, TrafficMode class HERETravelTimeData(TypedDict): @@ -32,3 +32,4 @@ class HERETravelTimeAPIParams: route_mode: RoutingMode arrival: datetime | None departure: datetime | None + traffic_mode: TrafficMode diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index c0534fa7154..639be3326f9 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -60,9 +60,11 @@ "step": { "init": { "data": { - "traffic_mode": "Traffic mode", - "route_mode": "Route mode", - "unit_system": "Unit system" + "traffic_mode": "Use traffic and time-aware routing", + "route_mode": "Route mode" + }, + "data_description": { + "traffic_mode": "Needed for defining arrival/departure times" } }, "time_menu": { diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index a3565f9ed77..efddabd180c 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, CONF_STATE @@ -11,7 +12,10 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.template import Template from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS @@ -20,6 +24,8 @@ from .data import HistoryStats type HistoryStatsConfigEntry = ConfigEntry[HistoryStatsUpdateCoordinator] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry @@ -47,6 +53,7 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -67,6 +74,7 @@ async def async_setup_entry( entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -83,6 +91,40 @@ async def async_setup_entry( return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the history_stats config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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 + + async def async_unload_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry ) -> bool: diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index ca3d5229b6b..750180bf3f6 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -3,11 +3,15 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import timedelta from typing import Any, cast import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -26,6 +30,7 @@ from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, ) +from homeassistant.helpers.template import Template from .const import ( CONF_DURATION, @@ -37,14 +42,21 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) +from .coordinator import HistoryStatsUpdateCoordinator +from .data import HistoryStats +from .sensor import HistoryStatsSensor + + +def _validate_two_period_keys(user_input: dict[str, Any]) -> None: + if sum(param in user_input for param in CONF_PERIOD_KEYS) != 2: + raise SchemaFlowError("only_two_keys_allowed") async def validate_options( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: """Validate options selected.""" - if sum(param in user_input for param in CONF_PERIOD_KEYS) != 2: - raise SchemaFlowError("only_two_keys_allowed") + _validate_two_period_keys(user_input) handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 @@ -97,12 +109,14 @@ CONFIG_FLOW = { "options": SchemaFlowFormStep( schema=DATA_SCHEMA_OPTIONS, validate_user_input=validate_options, + preview="history_stats", ), } OPTIONS_FLOW = { "init": SchemaFlowFormStep( DATA_SCHEMA_OPTIONS, validate_user_input=validate_options, + preview="history_stats", ), } @@ -110,9 +124,128 @@ OPTIONS_FLOW = { class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for History stats.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "history_stats/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + if msg["flow_type"] == "config_flow": + flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) + flow_sets = hass.config_entries.flow._handler_progress_index.get( # noqa: SLF001 + flow_status["handler"] + ) + options = {} + assert flow_sets + for active_flow in flow_sets: + options = active_flow._common_handler.options # type: ignore [attr-defined] # noqa: SLF001 + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + entity_id = options[CONF_ENTITY_ID] + name = options[CONF_NAME] + else: + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + if not config_entry: + raise HomeAssistantError("Config entry not found") + entity_id = config_entry.options[CONF_ENTITY_ID] + name = config_entry.options[CONF_NAME] + + @callback + def async_preview_updated( + last_exception: Exception | None, state: str, attributes: Mapping[str, Any] + ) -> None: + """Forward config entry state events to websocket.""" + if last_exception: + connection.send_message( + websocket_api.event_message( + msg["id"], {"error": str(last_exception) or "Unknown error"} + ) + ) + else: + connection.send_message( + websocket_api.event_message( + msg["id"], {"attributes": attributes, "state": state} + ) + ) + + for param in CONF_PERIOD_KEYS: + if param in msg["user_input"] and not bool(msg["user_input"][param]): + del msg["user_input"][param] # Remove falsy values before counting keys + + validated_data: Any = None + try: + validated_data = DATA_SCHEMA_OPTIONS(msg["user_input"]) + except vol.Invalid as ex: + connection.send_error(msg["id"], "invalid_schema", str(ex)) + return + + try: + _validate_two_period_keys(validated_data) + except SchemaFlowError: + connection.send_error( + msg["id"], + "invalid_schema", + f"Exactly two of {', '.join(CONF_PERIOD_KEYS)} required", + ) + return + + sensor_type = validated_data.get(CONF_TYPE) + entity_states = validated_data.get(CONF_STATE) + start = validated_data.get(CONF_START) + end = validated_data.get(CONF_END) + duration = validated_data.get(CONF_DURATION) + + history_stats = HistoryStats( + hass, + entity_id, + entity_states, + Template(start, hass) if start else None, + Template(end, hass) if end else None, + timedelta(**duration) if duration else None, + True, + ) + coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True) + await coordinator.async_refresh() + preview_entity = HistoryStatsSensor( + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=None, + source_entity_id=entity_id, + ) + preview_entity.hass = hass + + connection.send_result(msg["id"]) + cancel_listener = coordinator.async_setup_state_listener() + cancel_preview = await preview_entity.async_start_preview(async_preview_updated) + + def unsub() -> None: + cancel_listener() + cancel_preview() + + connection.subscriptions[msg["id"]] = unsub diff --git a/homeassistant/components/history_stats/coordinator.py b/homeassistant/components/history_stats/coordinator.py index fafbb5d3ce0..091e1da6ad8 100644 --- a/homeassistant/components/history_stats/coordinator.py +++ b/homeassistant/components/history_stats/coordinator.py @@ -36,12 +36,14 @@ class HistoryStatsUpdateCoordinator(DataUpdateCoordinator[HistoryStatsState]): history_stats: HistoryStats, config_entry: ConfigEntry | None, name: str, + preview: bool = False, ) -> None: """Initialize DataUpdateCoordinator.""" self._history_stats = history_stats self._subscriber_count = 0 self._at_start_listener: CALLBACK_TYPE | None = None self._track_events_listener: CALLBACK_TYPE | None = None + self._preview = preview super().__init__( hass, _LOGGER, @@ -104,3 +106,8 @@ class HistoryStatsUpdateCoordinator(DataUpdateCoordinator[HistoryStatsState]): return await self._history_stats.async_update(None) except (TemplateError, TypeError, ValueError) as ex: raise UpdateFailed(ex) from ex + + async def async_refresh(self) -> None: + """Refresh data and log errors.""" + log_failures = not self._preview + await self._async_refresh(log_failures) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index fd950dbba23..569483df687 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -47,6 +47,7 @@ class HistoryStats: start: Template | None, end: Template | None, duration: datetime.timedelta | None, + preview: bool = False, ) -> None: """Init the history stats manager.""" self.hass = hass @@ -59,6 +60,7 @@ class HistoryStats: self._duration = duration self._start = start self._end = end + self._preview = preview self._pending_events: list[Event[EventStateChangedData]] = [] self._query_count = 0 @@ -70,7 +72,9 @@ class HistoryStats: # Get previous values of start and end previous_period_start, previous_period_end = self._period # Parse templates - self._period = async_calculate_period(self._duration, self._start, self._end) + self._period = async_calculate_period( + self._duration, self._start, self._end, log_errors=not self._preview + ) # Get the current period current_period_start, current_period_end = self._period diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 99214a51369..b0ed132c1ef 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -23,6 +23,7 @@ def async_calculate_period( duration: datetime.timedelta | None, start_template: Template | None, end_template: Template | None, + log_errors: bool = True, ) -> tuple[datetime.datetime, datetime.datetime]: """Parse the templates and return the period.""" bounds: dict[str, datetime.datetime | None] = { @@ -37,13 +38,17 @@ def async_calculate_period( if template is None: continue try: - rendered = template.async_render() + rendered = template.async_render( + log_fn=None if log_errors else lambda *args, **kwargs: None + ) except (TemplateError, TypeError) as ex: - if ex.args and not ex.args[0].startswith( - "UndefinedError: 'None' has no attribute" + if ( + log_errors + and ex.args + and not ex.args[0].startswith("UndefinedError: 'None' has no attribute") ): _LOGGER.error("Error parsing template for field %s", bound, exc_info=ex) - raise + raise type(ex)(f"Error parsing template for field {bound}: {ex}") from ex if isinstance(rendered, str): bounds[bound] = dt_util.parse_datetime(rendered) if bounds[bound] is not None: diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 6935b13bc3d..0cfe82e09fb 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable, Mapping import datetime from typing import Any @@ -23,10 +24,10 @@ from homeassistant.const import ( PERCENTAGE, UnitOfTime, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -112,7 +113,16 @@ async def async_setup_platform( if not coordinator.last_update_success: raise PlatformNotReady from coordinator.last_exception async_add_entities( - [HistoryStatsSensor(hass, coordinator, sensor_type, name, unique_id, entity_id)] + [ + HistoryStatsSensor( + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=unique_id, + source_entity_id=entity_id, + ) + ] ) @@ -129,7 +139,12 @@ async def async_setup_entry( async_add_entities( [ HistoryStatsSensor( - hass, coordinator, sensor_type, entry.title, entry.entry_id, entity_id + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=entry.title, + unique_id=entry.entry_id, + source_entity_id=entity_id, ) ] ) @@ -175,6 +190,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase): def __init__( self, hass: HomeAssistant, + *, coordinator: HistoryStatsUpdateCoordinator, sensor_type: str, name: str, @@ -183,13 +199,17 @@ class HistoryStatsSensor(HistoryStatsSensorBase): ) -> None: """Initialize the HistoryStats sensor.""" super().__init__(coordinator, name) + self._preview_callback: ( + Callable[[Exception | None, str, Mapping[str, Any]], None] | None + ) = None self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type self._attr_unique_id = unique_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION @@ -212,3 +232,29 @@ class HistoryStatsSensor(HistoryStatsSensorBase): self._attr_native_value = pretty_ratio(state.seconds_matched, state.period) elif self._type == CONF_TYPE_COUNT: self._attr_native_value = state.match_count + + if self._preview_callback: + calculated_state = self._async_calculate_state() + self._preview_callback( + None, calculated_state.state, calculated_state.attributes + ) + + async def async_start_preview( + self, + preview_callback: Callable[[Exception | None, str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + self.async_on_remove( + self.coordinator.async_add_listener(self._process_update, None) + ) + + self._preview_callback = preview_callback + calculated_state = self._async_calculate_state() + preview_callback( + self.coordinator.last_exception, + calculated_state.state, + calculated_state.attributes, + ) + + return self._call_on_remove_callbacks diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py index b364f2c67a4..f0c340785cf 100644 --- a/homeassistant/components/holiday/__init__.py +++ b/homeassistant/components/holiday/__init__.py @@ -34,16 +34,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - 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/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 538d9971109..e9f16a9e4c5 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY from homeassistant.core import callback @@ -227,7 +227,7 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HolidayOptionsFlowHandler(OptionsFlow): +class HolidayOptionsFlowHandler(OptionsFlowWithReload): """Handle Holiday options.""" async def async_step_init( diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index c76d6638730..5ea0d217f14 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.75", "babel==2.15.0"] + "requirements": ["holidays==0.79", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 01f2acd1851..4a48d1f1ad7 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .services import register_actions +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ PLATFORMS = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - register_actions(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 3c9d33424a8..81f785b55ae 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -38,10 +38,15 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import ( + API_DEFAULT_RETRY_AFTER, + APPLIANCES_WITH_PROGRAMS, + BSH_OPERATION_STATE_PAUSE, + DOMAIN, +) from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -66,6 +71,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.clear() self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected @@ -201,6 +207,28 @@ class HomeConnectCoordinator( 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: @@ -598,42 +626,37 @@ class HomeConnectCoordinator( """Check if the appliance data hasn't been refreshed too often recently.""" now = self.hass.loop.time() - if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: - return True + + execution_tracker = self._execution_tracker[appliance_ha_id] + initial_len = len(execution_tracker) execution_tracker = self._execution_tracker[appliance_ha_id] = [ timestamp - for timestamp in self._execution_tracker[appliance_ha_id] + for timestamp in execution_tracker if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW ] execution_tracker.append(now) if len(execution_tracker) >= MAX_EXECUTIONS: - ir.async_create_issue( - self.hass, - DOMAIN, - f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.ERROR, - translation_key="home_connect_too_many_connected_paired_events", - data={ - "entry_id": self.config_entry.entry_id, - "appliance_ha_id": appliance_ha_id, - }, - translation_placeholders={ - "appliance_name": self.data[appliance_ha_id].info.name, - "times": str(MAX_EXECUTIONS), - "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), - "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", - "home_assistant_core_new_issue_url": ( - "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" - f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" - ), - }, - ) + if initial_len < MAX_EXECUTIONS: + _LOGGER.warning( + 'Too many connected/paired events for appliance "%s" ' + "(%s times in less than %s minutes), updates have been disabled " + "and they will be enabled again whenever the connection stabilizes. " + "Consider trying to unplug the appliance " + "for a while to perform a soft reset", + self.data[appliance_ha_id].info.name, + MAX_EXECUTIONS, + MAX_EXECUTIONS_TIME_WINDOW // 60, + ) return True + if initial_len >= MAX_EXECUTIONS: + _LOGGER.info( + 'Connected/paired events from the appliance "%s" have stabilized,' + " updates have been re-enabled", + self.data[appliance_ha_id].info.name, + ) return False diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index 59856999ec7..f5f4999fa2e 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from aiohomeconnect.model import GetSetting, Status + from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -11,14 +13,30 @@ from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry +def _serialize_item(item: Status | GetSetting) -> dict[str, Any]: + """Serialize a status or setting item to a dictionary.""" + data = {"value": item.value} + if item.unit is not None: + data["unit"] = item.unit + if item.constraints is not None: + data["constraints"] = { + k: v for k, v in item.constraints.to_dict().items() if v is not None + } + return data + + async def _generate_appliance_diagnostics( appliance: HomeConnectApplianceData, ) -> dict[str, Any]: return { **appliance.info.to_dict(), - "status": {key.value: status.value for key, status in appliance.status.items()}, + "status": { + key.value: _serialize_item(status) + for key, status in appliance.status.items() + }, "settings": { - key.value: setting.value for key, setting in appliance.settings.items() + key.value: _serialize_item(setting) + for key, setting in appliance.settings.items() }, "programs": [program.raw_key for program in appliance.programs], } diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 8ced21ecba5..2008e618f5e 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -14,7 +14,7 @@ "macaddress": "68A40E*" }, { - "hostname": "(siemens|neff)-*", + "hostname": "(bosch|neff|siemens)-*", "macaddress": "38B4D3*" } ], diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index fac1c5fe1a9..09c2f4a967d 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -18,7 +18,7 @@ from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +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.issue_registry import IssueSeverity, async_create_issue @@ -522,7 +522,8 @@ async def async_service_start_program(call: ServiceCall) -> None: await _async_service_program(call, True) -def register_actions(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register custom actions.""" hass.services.async_register( diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 99c89ec8788..fa24177a967 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -124,17 +124,6 @@ } }, "issues": { - "home_connect_too_many_connected_paired_events": { - "title": "{appliance_name} sent too many connected or paired events", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", - "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." - } - } - } - }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { @@ -204,11 +193,11 @@ "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner Brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser Brauner", "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener Melange", "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", @@ -290,7 +279,7 @@ "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", "cooking_oven_program_heating_mode_keep_warm": "Keep warm", "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", - "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products", "cooking_oven_program_heating_mode_desiccation": "Desiccation", "cooking_oven_program_heating_mode_defrost": "Defrost", "cooking_oven_program_heating_mode_proof": "Proof", @@ -327,8 +316,8 @@ "laundry_care_washer_program_monsoon": "Monsoon", "laundry_care_washer_program_outdoor": "Outdoor", "laundry_care_washer_program_plush_toy": "Plush toy", - "laundry_care_washer_program_shirts_blouses": "Shirts blouses", - "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_shirts_blouses": "Shirts/blouses", + "laundry_care_washer_program_sport_fitness": "Sport/fitness", "laundry_care_washer_program_towels": "Towels", "laundry_care_washer_program_water_proof": "Water proof", "laundry_care_washer_program_power_speed_59": "Power speed <59 min", @@ -593,7 +582,7 @@ }, "consumer_products_cleaning_robot_option_cleaning_mode": { "name": "Cleaning mode", - "description": "Defines the favoured cleaning mode." + "description": "Defines the favored cleaning mode." }, "consumer_products_coffee_maker_option_bean_amount": { "name": "Bean amount", @@ -681,7 +670,7 @@ }, "cooking_oven_option_setpoint_temperature": { "name": "Setpoint temperature", - "description": "Defines the target cavity temperature, which will be hold by the oven." + "description": "Defines the target cavity temperature, which will be held by the oven." }, "b_s_h_common_option_duration": { "name": "Duration", @@ -1302,9 +1291,9 @@ "state": { "cooking_hood_enum_type_color_temperature_custom": "Custom", "cooking_hood_enum_type_color_temperature_warm": "Warm", - "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral", + "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to neutral", "cooking_hood_enum_type_color_temperature_neutral": "Neutral", - "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold", + "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to cold", "cooking_hood_enum_type_color_temperature_cold": "Cold" } }, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index d5dabfa2e08..32fe690f0f1 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -44,11 +44,14 @@ from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service import ( async_extract_config_entry_ids, - async_extract_referenced_entity_ids, async_register_admin_service, ) from homeassistant.helpers.signal import KEY_HA_STOP from homeassistant.helpers.system_info import async_get_system_info +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType @@ -111,7 +114,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_handle_turn_service(service: ServiceCall) -> None: """Handle calls to homeassistant.turn_on/off.""" - referenced = async_extract_referenced_entity_ids(hass, service) + referenced = async_extract_referenced_entity_ids( + hass, TargetSelectorData(service.data) + ) all_referenced = referenced.referenced | referenced.indirectly_referenced # Generic turn on/off method requires entity id diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 7c95680076c..77c29e7c495 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -40,7 +40,7 @@ }, "python_version": { "title": "Support for Python {current_python_version} is being removed", - "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." + "description": "Support for running Home Assistant in the currently used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." }, "config_entry_only": { "title": "The {domain} integration does not support YAML configuration", @@ -81,7 +81,7 @@ "title": "Integration {domain} not found", "fix_flow": { "abort": { - "issue_ignored": "Not existing integration {domain} ignored." + "issue_ignored": "Non-existent integration {domain} ignored." }, "step": { "init": { @@ -274,7 +274,7 @@ "message": "Failed to process the returned action response data, expected a dictionary, but got {response_data_type}." }, "service_should_be_blocking": { - "message": "A non blocking action call with argument {non_blocking_argument} can't be used together with argument {return_response}." + "message": "A non-blocking action call with argument {non_blocking_argument} can't be used together with argument {return_response}." } } } diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index e07d806d3dc..27c63742f7b 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_PLATFORM, STATE_UNAVAILABLE, STATE_UNKNOWN, + WEEKDAYS, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -37,6 +38,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +CONF_WEEKDAY = "weekday" + _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) @@ -74,6 +77,10 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): vol.All(cv.ensure_list, [_TIME_TRIGGER_SCHEMA]), + vol.Optional(CONF_WEEKDAY): vol.Any( + vol.In(WEEKDAYS), + vol.All(cv.ensure_list, [vol.In(WEEKDAYS)]), + ), } ) @@ -85,7 +92,7 @@ class TrackEntity(NamedTuple): callback: Callable -async def async_attach_trigger( +async def async_attach_trigger( # noqa: C901 hass: HomeAssistant, config: ConfigType, action: TriggerActionType, @@ -103,6 +110,18 @@ async def async_attach_trigger( description: str, now: datetime, *, entity_id: str | None = None ) -> None: """Listen for time changes and calls action.""" + # Check weekday filter if configured + if CONF_WEEKDAY in config: + weekday_config = config[CONF_WEEKDAY] + current_weekday = WEEKDAYS[now.weekday()] + + # Check if current weekday matches the configuration + if isinstance(weekday_config, str): + if current_weekday != weekday_config: + return + elif current_weekday not in weekday_config: + return + hass.async_run_hass_job( job, { diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py index c9a5c891328..6c4b2cb38e4 100644 --- a/homeassistant/components/homeassistant_hardware/coordinator.py +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -12,6 +12,7 @@ from ha_silabs_firmware_client import ( ManifestMissing, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,15 +25,21 @@ FIRMWARE_REFRESH_INTERVAL = timedelta(hours=8) class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): """Coordinator to manage firmware updates.""" - def __init__(self, hass: HomeAssistant, session: ClientSession, url: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + session: ClientSession, + url: str, + ) -> None: """Initialize the firmware update coordinator.""" super().__init__( hass, _LOGGER, name="firmware update coordinator", update_interval=FIRMWARE_REFRESH_INTERVAL, + config_entry=config_entry, ) - self.hass = hass self.session = session self.client = FirmwareUpdateClient(url, session) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 1b4840e5a98..3263b091ad5 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,6 +7,11 @@ import asyncio import logging from typing import Any +from aiohttp import ClientError +from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing +from universal_silabs_flasher.common import Version +from universal_silabs_flasher.firmware import NabuCasaMetadata + from homeassistant.components.hassio import ( AddonError, AddonInfo, @@ -22,17 +27,18 @@ from homeassistant.config_entries import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio -from . import silabs_multiprotocol_addon from .const import OTBR_DOMAIN, ZHA_DOMAIN from .util import ( ApplicationType, FirmwareInfo, OwningAddon, OwningIntegration, + async_flash_silabs_firmware, get_otbr_addon_manager, - get_zigbee_flasher_addon_manager, guess_firmware_info, guess_hardware_owners, probe_silabs_firmware_info, @@ -61,6 +67,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self.addon_install_task: asyncio.Task | None = None self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None + self.firmware_install_task: asyncio.Task | None = None + self.installing_firmware_name: str | None = None def _get_translation_placeholders(self) -> dict[str, str]: """Shared translation placeholders.""" @@ -77,22 +85,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): return placeholders - async def _async_set_addon_config( - self, config: dict, addon_manager: AddonManager - ) -> None: - """Set add-on config.""" - try: - await addon_manager.async_set_addon_options(config) - except AddonError as err: - _LOGGER.error(err) - raise AbortFlow( - "addon_set_config_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": addon_manager.addon_name, - }, - ) from err - async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo: """Return add-on info.""" try: @@ -150,6 +142,145 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ) ) + async def _install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: + assert self._device is not None + + if not self.firmware_install_task: + # Keep track of the firmware we're working with, for error messages + self.installing_firmware_name = firmware_name + + # Installing new firmware is only truly required if the wrong type is + # installed: upgrading to the latest release of the current firmware type + # isn't strictly necessary for functionality. + firmware_install_required = self._probed_firmware_info is None or ( + self._probed_firmware_info.firmware_type + != expected_installed_firmware_type + ) + + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(fw_update_url, session) + + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + except (StopIteration, TimeoutError, ClientError, ManifestMissing): + _LOGGER.warning( + "Failed to fetch firmware update manifest", exc_info=True + ) + + # Not having internet access should not prevent setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to index download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) + + if not firmware_install_required: + assert self._probed_firmware_info is not None + + # Make sure we do not downgrade the firmware + fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) + fw_version = fw_metadata.get_public_version() + probed_fw_version = Version(self._probed_firmware_info.firmware_version) + + if probed_fw_version >= fw_version: + _LOGGER.debug( + "Not downgrading firmware, installed %s is newer than available %s", + probed_fw_version, + fw_version, + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + try: + fw_data = await client.async_fetch_firmware(fw_manifest) + except (TimeoutError, ClientError, ValueError): + _LOGGER.warning("Failed to fetch firmware update", exc_info=True) + + # If we cannot download new firmware, we shouldn't block setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to image download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + # Otherwise, fail + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) + + self.firmware_install_task = self.hass.async_create_task( + async_flash_silabs_firmware( + hass=self.hass, + device=self._device, + fw_data=fw_data, + expected_installed_firmware_type=expected_installed_firmware_type, + bootloader_reset_type=None, + progress_callback=lambda offset, total: self.async_update_progress( + offset / total + ), + ), + f"Flash {firmware_name} firmware", + ) + + if not self.firmware_install_task.done(): + return self.async_show_progress( + step_id=step_id, + progress_action="install_firmware", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + progress_task=self.firmware_install_task, + ) + + try: + await self.firmware_install_task + except HomeAssistantError: + _LOGGER.exception("Failed to flash firmware") + return self.async_show_progress_done(next_step_id="firmware_install_failed") + + return self.async_show_progress_done(next_step_id=next_step_id) + + async def async_step_firmware_download_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware download failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + + async def async_step_firmware_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware install failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_install_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -160,68 +291,141 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): description_placeholders=self._get_translation_placeholders(), ) - # Allow the stick to be used with ZHA without flashing - if ( - self._probed_firmware_info is not None - and self._probed_firmware_info.firmware_type == ApplicationType.EZSP - ): - return await self.async_step_confirm_zigbee() + return await self.async_step_install_zigbee_firmware() - if not is_hassio(self.hass): - return self.async_abort( - reason="not_hassio", - description_placeholders=self._get_translation_placeholders(), - ) + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + raise NotImplementedError - # Only flash new firmware if we need to - fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(fw_flasher_manager) - - if addon_info.state == AddonState.NOT_INSTALLED: - return await self.async_step_install_zigbee_flasher_addon() - - if addon_info.state == AddonState.NOT_RUNNING: - return await self.async_step_run_zigbee_flasher_addon() - - # If the addon is already installed and running, fail + async def async_step_addon_operation_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when add-on installation or start failed.""" return self.async_abort( - reason="addon_already_running", + reason=self._failed_addon_reason, description_placeholders={ **self._get_translation_placeholders(), - "addon_name": fw_flasher_manager.addon_name, + "addon_name": self._failed_addon_name, }, ) - async def async_step_install_zigbee_flasher_addon( + async def async_step_pre_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Show progress dialog for installing the Zigbee flasher addon.""" - return await self._install_addon( - get_zigbee_flasher_addon_manager(self.hass), - "install_zigbee_flasher_addon", - "run_zigbee_flasher_addon", + """Pre-confirm Zigbee setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_zigbee() + + async def async_step_confirm_zigbee( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Zigbee setup.""" + assert self._device is not None + assert self._hardware_name is not None + + if user_input is None: + return self.async_show_form( + step_id="confirm_zigbee", + description_placeholders=self._get_translation_placeholders(), + ) + + if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + + await self.hass.config_entries.flow.async_init( + ZHA_DOMAIN, + context={"source": "hardware"}, + data={ + "name": self._hardware_name, + "port": { + "path": self._device, + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "ezsp", + }, ) - async def _install_addon( - self, - addon_manager: silabs_multiprotocol_addon.WaitingAddonManager, - step_id: str, - next_step_id: str, + return self._async_flow_finished() + + async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None: + """Ensure the OTBR addon is set up and not running.""" + + # We install the OTBR addon no matter what, since it is required to use Thread + if not is_hassio(self.hass): + return self.async_abort( + reason="not_hassio_thread", + description_placeholders=self._get_translation_placeholders(), + ) + + otbr_manager = get_otbr_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(otbr_manager) + + if addon_info.state == AddonState.NOT_INSTALLED: + return await self.async_step_install_otbr_addon() + + if addon_info.state == AddonState.RUNNING: + # We only fail setup if we have an instance of OTBR running *and* it's + # pointing to different hardware + if addon_info.options["device"] != self._device: + return self.async_abort( + reason="otbr_addon_already_running", + description_placeholders={ + **self._get_translation_placeholders(), + "addon_name": otbr_manager.addon_name, + }, + ) + + # Otherwise, stop the addon before continuing to flash firmware + await otbr_manager.async_stop_addon() + + return None + + async def async_step_pick_firmware_thread( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Show progress dialog for installing an addon.""" + """Pick Thread firmware.""" + if not await self._probe_firmware_info(): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + + if result := await self._ensure_thread_addon_setup(): + return result + + return await self.async_step_install_thread_firmware() + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + raise NotImplementedError + + async def async_step_install_otbr_addon( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show progress dialog for installing the OTBR addon.""" + addon_manager = get_otbr_addon_manager(self.hass) addon_info = await self._async_get_addon_info(addon_manager) - _LOGGER.debug("Flasher addon state: %s", addon_info) + _LOGGER.debug("OTBR addon info: %s", addon_info) if not self.addon_install_task: self.addon_install_task = self.hass.async_create_task( addon_manager.async_install_addon_waiting(), - "Addon install", + "OTBR addon install", ) if not self.addon_install_task.done(): return self.async_show_progress( - step_id=step_id, + step_id="install_otbr_addon", progress_action="install_addon", description_placeholders={ **self._get_translation_placeholders(), @@ -240,208 +444,50 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): finally: self.addon_install_task = None - return self.async_show_progress_done(next_step_id=next_step_id) - - async def async_step_addon_operation_failed( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Abort when add-on installation or start failed.""" - return self.async_abort( - reason=self._failed_addon_reason, - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": self._failed_addon_name, - }, - ) - - async def async_step_run_zigbee_flasher_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure the flasher addon to point to the SkyConnect and run it.""" - fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(fw_flasher_manager) - - assert self._device is not None - new_addon_config = { - **addon_info.options, - "device": self._device, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - - _LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config) - await self._async_set_addon_config(new_addon_config, fw_flasher_manager) - - if not self.addon_start_task: - - async def start_and_wait_until_done() -> None: - await fw_flasher_manager.async_start_addon_waiting() - # Now that the addon is running, wait for it to finish - await fw_flasher_manager.async_wait_until_addon_state( - AddonState.NOT_RUNNING - ) - - self.addon_start_task = self.hass.async_create_task( - start_and_wait_until_done() - ) - - if not self.addon_start_task.done(): - return self.async_show_progress( - step_id="run_zigbee_flasher_addon", - progress_action="run_zigbee_flasher_addon", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": fw_flasher_manager.addon_name, - }, - progress_task=self.addon_start_task, - ) - - try: - await self.addon_start_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - self._failed_addon_name = fw_flasher_manager.addon_name - self._failed_addon_reason = "addon_start_failed" - return self.async_show_progress_done(next_step_id="addon_operation_failed") - finally: - self.addon_start_task = None - - return self.async_show_progress_done( - next_step_id="uninstall_zigbee_flasher_addon" - ) - - async def async_step_uninstall_zigbee_flasher_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Uninstall the flasher addon.""" - fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass) - - if not self.addon_uninstall_task: - _LOGGER.debug("Uninstalling flasher addon") - self.addon_uninstall_task = self.hass.async_create_task( - fw_flasher_manager.async_uninstall_addon_waiting() - ) - - if not self.addon_uninstall_task.done(): - return self.async_show_progress( - step_id="uninstall_zigbee_flasher_addon", - progress_action="uninstall_zigbee_flasher_addon", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": fw_flasher_manager.addon_name, - }, - progress_task=self.addon_uninstall_task, - ) - - try: - await self.addon_uninstall_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - # The uninstall failing isn't critical so we can just continue - finally: - self.addon_uninstall_task = None - - return self.async_show_progress_done(next_step_id="confirm_zigbee") - - async def async_step_confirm_zigbee( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm Zigbee setup.""" - assert self._device is not None - assert self._hardware_name is not None - - if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - - if user_input is not None: - await self.hass.config_entries.flow.async_init( - ZHA_DOMAIN, - context={"source": "hardware"}, - data={ - "name": self._hardware_name, - "port": { - "path": self._device, - "baudrate": 115200, - "flow_control": "hardware", - }, - "radio_type": "ezsp", - }, - ) - - return self._async_flow_finished() - - return self.async_show_form( - step_id="confirm_zigbee", - description_placeholders=self._get_translation_placeholders(), - ) - - async def async_step_pick_firmware_thread( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Pick Thread firmware.""" - if not await self._probe_firmware_info(): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - - # We install the OTBR addon no matter what, since it is required to use Thread - if not is_hassio(self.hass): - return self.async_abort( - reason="not_hassio_thread", - description_placeholders=self._get_translation_placeholders(), - ) - - otbr_manager = get_otbr_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(otbr_manager) - - if addon_info.state == AddonState.NOT_INSTALLED: - return await self.async_step_install_otbr_addon() - - if addon_info.state == AddonState.NOT_RUNNING: - return await self.async_step_start_otbr_addon() - - # If the addon is already installed and running, fail - return self.async_abort( - reason="otbr_addon_already_running", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": otbr_manager.addon_name, - }, - ) - - async def async_step_install_otbr_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Show progress dialog for installing the OTBR addon.""" - return await self._install_addon( - get_otbr_addon_manager(self.hass), "install_otbr_addon", "start_otbr_addon" - ) + return self.async_show_progress_done(next_step_id="install_thread_firmware") async def async_step_start_otbr_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure OTBR to point to the SkyConnect and run the addon.""" otbr_manager = get_otbr_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(otbr_manager) - - assert self._device is not None - new_addon_config = { - **addon_info.options, - "device": self._device, - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": True, - } - - _LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config) - await self._async_set_addon_config(new_addon_config, otbr_manager) if not self.addon_start_task: + # Before we start the addon, confirm that the correct firmware is running + # and populate `self._probed_firmware_info` with the correct information + if not await self._probe_firmware_info( + probe_methods=(ApplicationType.SPINEL,) + ): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + + addon_info = await self._async_get_addon_info(otbr_manager) + + assert self._device is not None + new_addon_config = { + **addon_info.options, + "device": self._device, + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + } + + _LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config) + + try: + await otbr_manager.async_set_addon_options(new_addon_config) + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow( + "addon_set_config_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "addon_name": otbr_manager.addon_name, + }, + ) from err + self.addon_start_task = self.hass.async_create_task( otbr_manager.async_start_addon_waiting() ) @@ -467,7 +513,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): finally: self.addon_start_task = None - return self.async_show_progress_done(next_step_id="confirm_otbr") + return self.async_show_progress_done(next_step_id="pre_confirm_otbr") + + async def async_step_pre_confirm_otbr( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm OTBR setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_otbr() async def async_step_confirm_otbr( self, user_input: dict[str, Any] | None = None @@ -475,20 +529,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Confirm OTBR setup.""" assert self._device is not None - if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)): - return self.async_abort( - reason="unsupported_firmware", + if user_input is None: + return self.async_show_form( + step_id="confirm_otbr", description_placeholders=self._get_translation_placeholders(), ) - if user_input is not None: - # OTBR discovery is done automatically via hassio - return self._async_flow_finished() - - return self.async_show_form( - step_id="confirm_otbr", - description_placeholders=self._get_translation_placeholders(), - ) + # OTBR discovery is done automatically via hassio + return self._async_flow_finished() @abstractmethod def _async_flow_finished(self) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index e184f9b3a85..da2374de57b 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -10,22 +10,6 @@ "pick_firmware_thread": "Thread" } }, - "install_zigbee_flasher_addon": { - "title": "Installing flasher", - "description": "Installing the Silicon Labs Flasher add-on." - }, - "run_zigbee_flasher_addon": { - "title": "Installing Zigbee firmware", - "description": "Installing Zigbee firmware. This will take about a minute." - }, - "uninstall_zigbee_flasher_addon": { - "title": "Removing flasher", - "description": "Removing the Silicon Labs Flasher add-on." - }, - "zigbee_flasher_failed": { - "title": "Zigbee installation failed", - "description": "The Zigbee firmware installation process was unsuccessful. Ensure no other software is trying to communicate with the {model} and try again." - }, "confirm_zigbee": { "title": "Zigbee setup complete", "description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration." @@ -52,12 +36,12 @@ "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "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.", "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." + "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.", + "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." }, "progress": { - "install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.", - "run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.", - "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed." + "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." } } }, @@ -110,16 +94,6 @@ "data": { "disable_multi_pan": "Disable multiprotocol support" } - }, - "install_flasher_addon": { - "title": "The Silicon Labs Flasher add-on installation has started" - }, - "configure_flasher_addon": { - "title": "The Silicon Labs Flasher add-on installation has started" - }, - "start_flasher_addon": { - "title": "Installing firmware", - "description": "Zigbee firmware is now being installed. This will take a few minutes." } }, "error": { diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index 1b0f15ca021..831d9f3f4da 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -2,15 +2,12 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable -from contextlib import AsyncExitStack, asynccontextmanager +from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any, cast from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata -from universal_silabs_flasher.firmware import parse_firmware_image -from universal_silabs_flasher.flasher import Flasher from yarl import URL from homeassistant.components.update import ( @@ -20,18 +17,12 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.restore_state import ExtraStoredData from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import FirmwareUpdateCoordinator from .helpers import async_register_firmware_info_callback -from .util import ( - ApplicationType, - FirmwareInfo, - guess_firmware_info, - probe_silabs_firmware_info, -) +from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware _LOGGER = logging.getLogger(__name__) @@ -249,19 +240,11 @@ class BaseFirmwareUpdateEntity( self._attr_update_percentage = round((offset * 100) / total_size) self.async_write_ha_state() - @asynccontextmanager - async def _temporarily_stop_hardware_owners( - self, device: str - ) -> AsyncIterator[None]: - """Temporarily stop addons and integrations communicating with the device.""" - firmware_info = await guess_firmware_info(self.hass, device) - _LOGGER.debug("Identified firmware info: %s", firmware_info) - - async with AsyncExitStack() as stack: - for owner in firmware_info.owners: - await stack.enter_async_context(owner.temporarily_stop(self.hass)) - - yield + # Switch to an indeterminate progress bar after installation is complete, since + # we probe the firmware after flashing + if offset == total_size: + self._attr_update_percentage = None + self.async_write_ha_state() async def async_install( self, version: str | None, backup: bool, **kwargs: Any @@ -278,49 +261,18 @@ class BaseFirmwareUpdateEntity( fw_data = await self.coordinator.client.async_fetch_firmware( self._latest_firmware ) - fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data) - device = self._current_device + try: + firmware_info = await async_flash_silabs_firmware( + hass=self.hass, + device=self._current_device, + fw_data=fw_data, + expected_installed_firmware_type=self.entity_description.expected_firmware_type, + bootloader_reset_type=self.bootloader_reset_type, + progress_callback=self._update_progress, + ) + finally: + self._attr_in_progress = False + self.async_write_ha_state() - flasher = Flasher( - device=device, - probe_methods=( - ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), - ApplicationType.EZSP.as_flasher_application_type(), - ApplicationType.SPINEL.as_flasher_application_type(), - ApplicationType.CPC.as_flasher_application_type(), - ), - bootloader_reset=self.bootloader_reset_type, - ) - - async with self._temporarily_stop_hardware_owners(device): - try: - try: - # Enter the bootloader with indeterminate progress - await flasher.enter_bootloader() - - # Flash the firmware, with progress - await flasher.flash_firmware( - fw_image, progress_callback=self._update_progress - ) - except Exception as err: - raise HomeAssistantError("Failed to flash firmware") from err - - # Probe the running application type with indeterminate progress - self._attr_update_percentage = None - self.async_write_ha_state() - - firmware_info = await probe_silabs_firmware_info( - device, - probe_methods=(self.entity_description.expected_firmware_type,), - ) - - if firmware_info is None: - raise HomeAssistantError( - "Failed to probe the firmware after flashing" - ) - - self._firmware_info_callback(firmware_info) - finally: - self._attr_in_progress = False - self.async_write_ha_state() + self._firmware_info_callback(firmware_info) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 64f363e4f23..d84f4f75ff7 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -4,18 +4,20 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import AsyncIterator, Iterable -from contextlib import asynccontextmanager +from collections.abc import AsyncIterator, Callable, Iterable +from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from enum import StrEnum import logging from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType +from universal_silabs_flasher.firmware import parse_firmware_image from universal_silabs_flasher.flasher import Flasher from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton @@ -333,3 +335,52 @@ async def probe_silabs_firmware_type( return None return fw_info.firmware_type + + +async def async_flash_silabs_firmware( + hass: HomeAssistant, + device: str, + fw_data: bytes, + expected_installed_firmware_type: ApplicationType, + bootloader_reset_type: str | None = None, + progress_callback: Callable[[int, int], None] | None = None, +) -> FirmwareInfo: + """Flash firmware to the SiLabs device.""" + firmware_info = await guess_firmware_info(hass, device) + _LOGGER.debug("Identified firmware info: %s", firmware_info) + + fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data) + + flasher = Flasher( + device=device, + probe_methods=( + ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), + ApplicationType.EZSP.as_flasher_application_type(), + ApplicationType.SPINEL.as_flasher_application_type(), + ApplicationType.CPC.as_flasher_application_type(), + ), + bootloader_reset=bootloader_reset_type, + ) + + async with AsyncExitStack() as stack: + for owner in firmware_info.owners: + await stack.enter_async_context(owner.temporarily_stop(hass)) + + try: + # Enter the bootloader with indeterminate progress + await flasher.enter_bootloader() + + # Flash the firmware, with progress + await flasher.flash_firmware(fw_image, progress_callback=progress_callback) + except Exception as err: + raise HomeAssistantError("Failed to flash firmware") from err + + probed_firmware_info = await probe_silabs_firmware_info( + device, + probe_methods=(expected_installed_firmware_type,), + ) + + if probed_firmware_info is None: + raise HomeAssistantError("Failed to probe the firmware after flashing") + + return probed_firmware_info diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index eb5ea214b3e..197cb2ff2ce 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -32,6 +32,7 @@ from .const import ( FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, + NABU_CASA_FIRMWARE_RELEASES_URL, PID, PRODUCT, SERIAL_NUMBER, @@ -45,19 +46,29 @@ _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - class TranslationPlaceholderProtocol(Protocol): - """Protocol describing `BaseFirmwareInstallFlow`'s translation placeholders.""" + class FirmwareInstallFlowProtocol(Protocol): + """Protocol describing `BaseFirmwareInstallFlow` for a mixin.""" def _get_translation_placeholders(self) -> dict[str, str]: return {} + async def _install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: ... + else: # Multiple inheritance with `Protocol` seems to break - TranslationPlaceholderProtocol = object + FirmwareInstallFlowProtocol = object -class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProtocol): - """Translation placeholder mixin for Home Assistant SkyConnect.""" +class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): + """Mixin for Home Assistant SkyConnect firmware methods.""" context: ConfigFlowContext @@ -72,9 +83,35 @@ class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProt return placeholders + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="skyconnect_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="pre_confirm_zigbee", + ) + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="skyconnect_openthread_rcp", + firmware_name="OpenThread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="start_otbr_addon", + ) + class HomeAssistantSkyConnectConfigFlow( - SkyConnectTranslationMixin, + SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareConfigFlow, domain=DOMAIN, ): @@ -207,7 +244,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( class HomeAssistantSkyConnectOptionsFlowHandler( - SkyConnectTranslationMixin, firmware_config_flow.BaseFirmwareOptionsFlow + SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow ): """Zigbee and Thread options flow handlers.""" diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index a990f025e8d..13775d1f1eb 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -48,16 +48,6 @@ "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" } }, - "install_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]" - }, - "configure_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]" - }, - "start_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]" - }, "pick_firmware": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", @@ -66,18 +56,6 @@ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]" } }, - "install_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]" - }, - "run_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]" - }, - "zigbee_flasher_failed": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]" - }, "confirm_zigbee": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" @@ -114,15 +92,15 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", - "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", - "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" } }, "config": { @@ -136,22 +114,6 @@ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]" } }, - "install_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]" - }, - "run_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]" - }, - "uninstall_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::description%]" - }, - "zigbee_flasher_failed": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]" - }, "confirm_zigbee": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" @@ -185,15 +147,15 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", - "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", - "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" } }, "exceptions": { diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index 74c28b37eaf..df69b6d40a2 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -124,6 +124,7 @@ def _async_create_update_entity( config_entry=config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + config_entry, session, NABU_CASA_FIRMWARE_RELEASES_URL, ), diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 1fac6bcac96..db844d0b0e9 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, Protocol, final import aiohttp import voluptuous as vol @@ -31,6 +31,7 @@ from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.config_entries import ( SOURCE_HARDWARE, ConfigEntry, + ConfigEntryBaseFlow, ConfigFlowResult, OptionsFlow, ) @@ -41,6 +42,7 @@ from .const import ( DOMAIN, FIRMWARE, FIRMWARE_VERSION, + NABU_CASA_FIRMWARE_RELEASES_URL, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA, @@ -57,8 +59,59 @@ STEP_HW_SETTINGS_SCHEMA = vol.Schema( } ) +if TYPE_CHECKING: -class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): + class FirmwareInstallFlowProtocol(Protocol): + """Protocol describing `BaseFirmwareInstallFlow` for a mixin.""" + + async def _install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: ... + +else: + # Multiple inheritance with `Protocol` seems to break + FirmwareInstallFlowProtocol = object + + +class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): + """Mixin for Home Assistant Yellow firmware methods.""" + + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="yellow_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="confirm_zigbee", + ) + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="yellow_openthread_rcp", + firmware_name="OpenThread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="start_otbr_addon", + ) + + +class HomeAssistantYellowConfigFlow( + YellowFirmwareMixin, BaseFirmwareConfigFlow, domain=DOMAIN +): """Handle a config flow for Home Assistant Yellow.""" VERSION = 1 @@ -275,7 +328,9 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler( class HomeAssistantYellowOptionsFlowHandler( - BaseHomeAssistantYellowOptionsFlow, BaseFirmwareOptionsFlow + YellowFirmwareMixin, + BaseHomeAssistantYellowOptionsFlow, + BaseFirmwareOptionsFlow, ): """Handle a firmware options flow for Home Assistant Yellow.""" diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 41c1438b234..d0c5e969d11 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -71,16 +71,6 @@ "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" } }, - "install_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]" - }, - "configure_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]" - }, - "start_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]" - }, "pick_firmware": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", @@ -89,18 +79,6 @@ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]" } }, - "install_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]" - }, - "run_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]" - }, - "zigbee_flasher_failed": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]" - }, "confirm_zigbee": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" @@ -139,15 +117,15 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon 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 add-on is currently trying to communicate with the device.", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", - "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", - "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" } }, "entity": { diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index 9531bd456cb..7a6e2f19b1f 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -129,6 +129,7 @@ def _async_create_update_entity( config_entry=config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + config_entry, session, NABU_CASA_FIRMWARE_RELEASES_URL, ), diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 0f90752733d..d748d1dd809 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -7,7 +7,7 @@ from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedExcept from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import DOMAIN @@ -53,12 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo try: await homee.get_access_token() except HomeeConnectionFailedException as exc: - raise ConfigEntryNotReady( - f"Connection to Homee failed: {exc.__cause__}" - ) from exc + raise ConfigEntryNotReady(f"Connection to Homee failed: {exc.reason}") from exc except HomeeAuthFailedException as exc: - raise ConfigEntryNotReady( - f"Authentication to Homee failed: {exc.__cause__}" + raise ConfigEntryAuthFailed( + f"Authentication to Homee failed: {exc.reason}" ) from exc hass.loop.create_task(homee.run()) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index fcf03322d0d..44c9b70953b 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -1,5 +1,6 @@ """Config flow for homee integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -10,10 +11,16 @@ from pyHomee import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import DOMAIN +from .const import ( + DOMAIN, + RESULT_CANNOT_CONNECT, + RESULT_INVALID_AUTH, + RESULT_UNKNOWN_ERROR, +) _LOGGER = logging.getLogger(__name__) @@ -32,58 +39,194 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 homee: Homee + _host: str + _name: str + _reauth_host: str + _reauth_username: str + + async def _connect_homee(self) -> dict[str, str]: + errors: dict[str, str] = {} + try: + await self.homee.get_access_token() + except HomeeConnectionFailedException: + errors["base"] = RESULT_CANNOT_CONNECT + except HomeeAuthenticationFailedException: + errors["base"] = RESULT_INVALID_AUTH + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = RESULT_UNKNOWN_ERROR + else: + _LOGGER.info("Got access token for homee") + self.hass.loop.create_task(self.homee.run()) + _LOGGER.debug("Homee task created") + await self.homee.wait_until_connected() + _LOGGER.info("Homee connected") + self.homee.disconnect() + _LOGGER.debug("Homee disconnecting") + await self.homee.wait_until_disconnected() + _LOGGER.info("Homee config successfully tested") + + await self.async_set_unique_id( + self.homee.settings.uid, raise_on_progress=self.source != SOURCE_USER + ) + + self._abort_if_unique_id_configured() + + _LOGGER.info("Created new homee entry with ID %s", self.homee.settings.uid) + + return errors async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial user step.""" + errors: dict[str, str] = {} - errors = {} if user_input is not None: self.homee = Homee( user_input[CONF_HOST], user_input[CONF_USERNAME], user_input[CONF_PASSWORD], ) + errors = await self._connect_homee() - try: - await self.homee.get_access_token() - except HomeeConnectionFailedException: - errors["base"] = "cannot_connect" - except HomeeAuthenticationFailedException: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - _LOGGER.info("Got access token for homee") - self.hass.loop.create_task(self.homee.run()) - _LOGGER.debug("Homee task created") - await self.homee.wait_until_connected() - _LOGGER.info("Homee connected") - self.homee.disconnect() - _LOGGER.debug("Homee disconnecting") - await self.homee.wait_until_disconnected() - _LOGGER.info("Homee config successfully tested") - - await self.async_set_unique_id(self.homee.settings.uid) - - self._abort_if_unique_id_configured() - - _LOGGER.info( - "Created new homee entry with ID %s", self.homee.settings.uid - ) - + if not errors: return self.async_create_entry( title=f"{self.homee.settings.homee_name} ({self.homee.host})", data=user_input, ) + return self.async_show_form( step_id="user", data_schema=AUTH_SCHEMA, errors=errors, ) + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # Ensure that an IPv4 address is received + self._host = discovery_info.host + self._name = discovery_info.hostname[6:18] + if discovery_info.ip_address.version == 6: + return self.async_abort(reason="ipv6_address") + + await self.async_set_unique_id(self._name) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + # Cause an auth-error to see if homee is reachable. + self.homee = Homee( + self._host, + "dummy_username", + "dummy_password", + ) + errors = await self._connect_homee() + if errors["base"] != RESULT_INVALID_AUTH: + return self.async_abort(reason=RESULT_CANNOT_CONNECT) + + self.context["title_placeholders"] = {"name": self._name, "host": self._host} + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the configuration of the device.""" + + errors: dict[str, str] = {} + if user_input is not None: + self.homee = Homee( + self._host, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + errors = await self._connect_homee() + + if not errors: + return self.async_create_entry( + title=f"{self.homee.settings.homee_name} ({self.homee.host})", + data={ + CONF_HOST: self._host, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + description_placeholders={ + CONF_HOST: self._name, + }, + last_step=True, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + self._reauth_host = entry_data[CONF_HOST] + self._reauth_username = entry_data[CONF_USERNAME] + 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: + self.homee = Homee( + self._reauth_host, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + try: + await self.homee.get_access_token() + except HomeeConnectionFailedException: + errors["base"] = RESULT_CANNOT_CONNECT + except HomeeAuthenticationFailedException: + errors["base"] = RESULT_INVALID_AUTH + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = RESULT_UNKNOWN_ERROR + else: + self.hass.loop.create_task(self.homee.run()) + await self.homee.wait_until_connected() + self.homee.disconnect() + await self.homee.wait_until_disconnected() + + await self.async_set_unique_id(self.homee.settings.uid) + self._abort_if_unique_id_mismatch(reason="wrong_hub") + + _LOGGER.debug( + "Reauthenticated homee entry with ID %s", self.homee.settings.uid + ) + 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=vol.Schema( + { + vol.Required(CONF_USERNAME, default=self._reauth_username): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={ + "host": self._reauth_host, + }, + errors=errors, + ) + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -101,12 +244,12 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): try: await self.homee.get_access_token() except HomeeConnectionFailedException: - errors["base"] = "cannot_connect" + errors["base"] = RESULT_CANNOT_CONNECT except HomeeAuthenticationFailedException: - errors["base"] = "invalid_auth" + errors["base"] = RESULT_INVALID_AUTH except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors["base"] = RESULT_UNKNOWN_ERROR else: self.hass.loop.create_task(self.homee.run()) await self.homee.wait_until_connected() diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 7bc3de189d6..718baf346ae 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -20,6 +20,11 @@ from homeassistant.const import ( # General DOMAIN = "homee" +# Error strings +RESULT_CANNOT_CONNECT = "cannot_connect" +RESULT_INVALID_AUTH = "invalid_auth" +RESULT_UNKNOWN_ERROR = "unknown" + # Sensor mappings HOMEE_UNIT_TO_HA_UNIT = { "": None, diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 16169676835..35e89ec645a 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -7,6 +7,12 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["homee"], - "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.10"] + "quality_scale": "silver", + "requirements": ["pyHomee==1.2.10"], + "zeroconf": [ + { + "type": "_ssh._tcp.local.", + "name": "homee-*" + } + ] } diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index 906218cf823..5a8f987c1f9 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -28,16 +28,19 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: | + The integration does not have options. + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo - test-coverage: todo + reauthentication-flow: done + test-coverage: done # Gold devices: done @@ -49,16 +52,16 @@ rules: docs-known-limitations: todo docs-supported-devices: todo docs-supported-functions: todo - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: todo dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 8b10b3ebb8a..26fa335d147 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -3,8 +3,9 @@ "flow_title": "homee {name} ({host})", "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%]", - "wrong_hub": "Address belongs to a different homee." + "wrong_hub": "IP address belongs to a different homee than the configured one." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -25,6 +26,17 @@ "password": "The password for your homee." } }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::homee::config::step::user::data_description::username%]", + "password": "[%key:component::homee::config::step::user::data_description::password%]" + } + }, "reconfigure": { "title": "Reconfigure homee {name}", "description": "Reconfigure the IP address of your homee.", @@ -32,7 +44,18 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The IP address of your homee." + "host": "[%key:component::homee::config::step::user::data_description::host%]" + } + }, + "zeroconf_confirm": { + "title": "Configure discovered homee {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::homee::config::step::user::data_description::username%]", + "password": "[%key:component::homee::config::step::user::data_description::password%]" } } } diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 8b526b62302..50b11265cf4 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -75,11 +75,12 @@ from homeassistant.helpers.entityfilter import ( EntityFilter, ) from homeassistant.helpers.reload import async_integration_yaml_config -from homeassistant.helpers.service import ( - async_extract_referenced_entity_ids, - async_register_admin_service, -) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.start import async_at_started +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import create_eager_task @@ -482,7 +483,9 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def async_handle_homekit_unpair(service: ServiceCall) -> None: """Handle unpair HomeKit service call.""" - referenced = async_extract_referenced_entity_ids(hass, service) + referenced = async_extract_referenced_entity_ids( + hass, TargetSelectorData(service.data) + ) dev_reg = dr.async_get(hass) for device_id in referenced.referenced_devices: if not (dev_reg_ent := dev_reg.async_get(device_id)): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 95842d56094..681ebcbbef7 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -628,12 +628,12 @@ class HomeAccessory(Accessory): # type: ignore[misc] self, domain: str, service: str, - service_data: dict[str, Any] | None, + service_data: dict[str, Any], value: Any | None = None, ) -> None: """Fire event and call service for changes from HomeKit.""" event_data = { - ATTR_ENTITY_ID: self.entity_id, + ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id), ATTR_DISPLAY_NAME: self.display_name, ATTR_SERVICE: service, ATTR_VALUE: value, diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 44f18c30099..2d4e2b03079 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -57,6 +57,8 @@ CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor" CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor" CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor" +CONF_LINKED_VALVE_DURATION = "linked_valve_duration" +CONF_LINKED_VALVE_END_TIME = "linked_valve_end_time" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -229,10 +231,12 @@ CHAR_ON = "On" CHAR_OUTLET_IN_USE = "OutletInUse" CHAR_POSITION_STATE = "PositionState" CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent" +CHAR_REMAINING_DURATION = "RemainingDuration" CHAR_REMOTE_KEY = "RemoteKey" CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" +CHAR_SET_DURATION = "SetDuration" CHAR_SERIAL_NUMBER = "SerialNumber" CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex" CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace" diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 48327910be6..9fef970d560 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -291,7 +291,7 @@ class NitrogenDioxideSensor(AirQualitySensor): class VolatileOrganicCompoundsSensor(AirQualitySensor): """Generate a VolatileOrganicCompoundsSensor accessory as VOCs sensor. - Sensor entity must return VOC in µg/m3. + Sensor entity must return VOC in μg/m3. """ def create_services(self) -> None: diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 18150c820c3..c011b8cd327 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -15,6 +15,11 @@ from pyhap.const import ( ) from homeassistant.components import button, input_button +from homeassistant.components.input_number import ( + ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.lawn_mower import ( DOMAIN as LAWN_MOWER_DOMAIN, @@ -45,6 +50,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( @@ -54,7 +60,11 @@ from .const import ( CHAR_NAME, CHAR_ON, CHAR_OUTLET_IN_USE, + CHAR_REMAINING_DURATION, + CHAR_SET_DURATION, CHAR_VALVE_TYPE, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, SERV_OUTLET, SERV_SWITCH, SERV_VALVE, @@ -271,7 +281,21 @@ class ValveBase(HomeAccessory): self.on_service = on_service self.off_service = off_service - serv_valve = self.add_preload_service(SERV_VALVE) + self.chars = [] + + self.linked_duration_entity: str | None = self.config.get( + CONF_LINKED_VALVE_DURATION + ) + self.linked_end_time_entity: str | None = self.config.get( + CONF_LINKED_VALVE_END_TIME + ) + + if self.linked_duration_entity: + self.chars.append(CHAR_SET_DURATION) + if self.linked_end_time_entity: + self.chars.append(CHAR_REMAINING_DURATION) + + serv_valve = self.add_preload_service(SERV_VALVE, self.chars) self.char_active = serv_valve.configure_char( CHAR_ACTIVE, value=False, setter_callback=self.set_state ) @@ -279,6 +303,25 @@ class ValveBase(HomeAccessory): self.char_valve_type = serv_valve.configure_char( CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type ) + + if CHAR_SET_DURATION in self.chars: + _LOGGER.debug( + "%s: Add characteristic %s", self.entity_id, CHAR_SET_DURATION + ) + self.char_set_duration = serv_valve.configure_char( + CHAR_SET_DURATION, + value=self.get_duration(), + setter_callback=self.set_duration, + ) + + if CHAR_REMAINING_DURATION in self.chars: + _LOGGER.debug( + "%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION + ) + self.char_remaining_duration = serv_valve.configure_char( + CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration + ) + # Set the state so it is in sync on initial # GET to avoid an event storm after homekit startup self.async_update_state(state) @@ -294,12 +337,75 @@ class ValveBase(HomeAccessory): @callback def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" + self._update_duration_chars() current_state = 1 if new_state.state in self.open_states else 0 _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) self.char_active.set_value(current_state) _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) self.char_in_use.set_value(current_state) + def _update_duration_chars(self) -> None: + """Update valve duration related properties if characteristics are available.""" + if CHAR_SET_DURATION in self.chars: + self.char_set_duration.set_value(self.get_duration()) + if CHAR_REMAINING_DURATION in self.chars: + self.char_remaining_duration.set_value(self.get_remaining_duration()) + + def set_duration(self, value: int) -> None: + """Set default duration for how long the valve should remain open.""" + _LOGGER.debug("%s: Set default run time to %s", self.entity_id, value) + self.async_call_service( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: self.linked_duration_entity, + INPUT_NUMBER_ATTR_VALUE: value, + }, + value, + ) + + def get_duration(self) -> int: + """Get the default duration from Home Assistant.""" + duration_state = self._get_entity_state(self.linked_duration_entity) + if duration_state is None: + _LOGGER.debug( + "%s: No linked duration entity state available", self.entity_id + ) + return 0 + + try: + duration = float(duration_state) + return max(int(duration), 0) + except ValueError: + _LOGGER.debug("%s: Cannot parse linked duration entity", self.entity_id) + return 0 + + def get_remaining_duration(self) -> int: + """Calculate the remaining duration based on end time in Home Assistant.""" + end_time_state = self._get_entity_state(self.linked_end_time_entity) + if end_time_state is None: + _LOGGER.debug( + "%s: No linked end time entity state available", self.entity_id + ) + return self.get_duration() + + end_time = dt_util.parse_datetime(end_time_state) + if end_time is None: + _LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id) + return self.get_duration() + + remaining_time = (end_time - dt_util.utcnow()).total_seconds() + return max(int(remaining_time), 0) + + def _get_entity_state(self, entity_id: str | None) -> str | None: + """Fetch the state of a linked entity.""" + if entity_id is None: + return None + state = self.hass.states.get(entity_id) + if state is None: + return None + return state.state + @TYPES.register("ValveSwitch") class ValveSwitch(ValveBase): diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 85207e09626..9a0a288fad4 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -17,6 +17,7 @@ import voluptuous as vol from homeassistant.components import ( binary_sensor, + input_number, media_player, persistent_notification, sensor, @@ -69,6 +70,8 @@ from .const import ( CONF_LINKED_OBSTRUCTION_SENSOR, CONF_LINKED_PM25_SENSOR, CONF_LINKED_TEMPERATURE_SENSOR, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -266,7 +269,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( TYPE_VALVE, ) ), - ) + ), + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN), + vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN), } ) @@ -277,6 +282,12 @@ SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN), + vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN), + } +) HOMEKIT_CHAR_TRANSLATIONS = { 0: " ", # nul @@ -360,6 +371,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "sensor": config = SENSOR_SCHEMA(config) + elif domain == "valve": + config = VALVE_SCHEMA(config) + else: config = BASIC_INFO_SCHEMA(config) @@ -480,7 +494,7 @@ def temperature_to_states(temperature: float, unit: str) -> float: def density_to_air_quality(density: float) -> int: - """Map PM2.5 µg/m3 density to HomeKit AirQuality level.""" + """Map PM2.5 μg/m3 density to HomeKit AirQuality level.""" if density <= 9: # US AQI 0-50 (HomeKit: Excellent) return 1 if density <= 35.4: # US AQI 51-100 (HomeKit: Good) @@ -493,7 +507,7 @@ def density_to_air_quality(density: float) -> int: def density_to_air_quality_pm10(density: float) -> int: - """Map PM10 µg/m3 density to HomeKit AirQuality level.""" + """Map PM10 μg/m3 density to HomeKit AirQuality level.""" if density <= 54: # US AQI 0-50 (HomeKit: Excellent) return 1 if density <= 154: # US AQI 51-100 (HomeKit: Good) @@ -506,7 +520,7 @@ def density_to_air_quality_pm10(density: float) -> int: def density_to_air_quality_nitrogen_dioxide(density: float) -> int: - """Map nitrogen dioxide µg/m3 to HomeKit AirQuality level.""" + """Map nitrogen dioxide μg/m3 to HomeKit AirQuality level.""" if density <= 30: return 1 if density <= 60: @@ -519,7 +533,7 @@ def density_to_air_quality_nitrogen_dioxide(density: float) -> int: def density_to_air_quality_voc(density: float) -> int: - """Map VOCs µg/m3 to HomeKit AirQuality level. + """Map VOCs μg/m3 to HomeKit AirQuality level. The VOC mappings use the IAQ guidelines for Europe released by the WHO (World Health Organization). Referenced from Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 2b72794b323..d4c0b1a45ca 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.LOCK, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, Platform.WEATHER, ] diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index f9986e0c526..e846a360d39 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -12,6 +12,7 @@ from homematicip.device import ( FullFlushShutter, GarageDoorModuleTormatic, HoermannDrivesModule, + WiredDinRailBlind4, ) from homematicip.group import ExtendedLinkedShutterGroup @@ -48,7 +49,7 @@ async def async_setup_entry( for device in hap.home.devices: if isinstance(device, BlindModule): entities.append(HomematicipBlindModule(hap, device)) - elif isinstance(device, DinRailBlind4): + elif isinstance(device, (DinRailBlind4, WiredDinRailBlind4)): entities.extend( HomematicipMultiCoverSlats(hap, device, channel=channel) for channel in range(1, 5) @@ -282,19 +283,19 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" - return self._device.doorState == DoorState.CLOSED + return self.functional_channel.doorState == DoorState.CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.send_door_command_async(DoorCommand.OPEN) + await self.functional_channel.async_send_door_command(DoorCommand.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.send_door_command_async(DoorCommand.CLOSE) + await self.functional_channel.async_send_door_command(DoorCommand.CLOSE) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._device.send_door_command_async(DoorCommand.STOP) + await self.functional_channel.async_send_door_command(DoorCommand.STOP) class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index c42ebff200d..d66594da390 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -113,9 +113,7 @@ class HomematicipHAP: self._ws_close_requested = False self._ws_connection_closed = asyncio.Event() - self._retry_task: asyncio.Task | None = None - self._tries = 0 - self._accesspoint_connected = True + self._get_state_task: asyncio.Task | None = None self.hmip_device_by_entity_id: dict[str, Any] = {} self.reset_connection_listener: Callable | None = None @@ -161,17 +159,8 @@ class HomematicipHAP: """ if not self.home.connected: _LOGGER.error("HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False + self._ws_connection_closed.set() self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Now the HOME_CHANGED event has fired indicating the access - # point has reconnected to the cloud again. - # Explicitly getting an update as entity states might have - # changed during access point disconnect.""" - - job = self.hass.async_create_task(self.get_state()) - job.add_done_callback(self.get_state_finished) - self._accesspoint_connected = True @callback def async_create_entity(self, *args, **kwargs) -> None: @@ -185,20 +174,43 @@ class HomematicipHAP: await asyncio.sleep(30) await self.hass.config_entries.async_reload(self.config_entry.entry_id) + async def _try_get_state(self) -> None: + """Call get_state in a loop until no error occurs, using exponential backoff on error.""" + + # Wait until WebSocket connection is established. + while not self.home.websocket_is_connected(): + await asyncio.sleep(2) + + delay = 8 + max_delay = 1500 + while True: + try: + await self.get_state() + break + except HmipConnectionError as err: + _LOGGER.warning( + "Get_state failed, retrying in %s seconds: %s", delay, err + ) + await asyncio.sleep(delay) + delay = min(delay * 2, max_delay) + async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: - """Execute when get_state coroutine has finished.""" + """Execute when try_get_state coroutine has finished.""" try: future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error("Updating state after HMIP access point reconnect failed") - self.hass.async_create_task(self.home.disable_events()) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error updating state after HMIP access point reconnect: %s", err + ) + else: + _LOGGER.info( + "Updating state after HMIP access point reconnect finished successfully", + ) def set_all_to_unavailable(self) -> None: """Set all devices to unavailable and tell Home Assistant.""" @@ -222,8 +234,8 @@ class HomematicipHAP: async def async_reset(self) -> bool: """Close the websocket connection.""" self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() + if self._get_state_task is not None: + self._get_state_task.cancel() await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( @@ -247,7 +259,9 @@ class HomematicipHAP: """Handle websocket connected.""" _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): - await self.get_state() + self._get_state_task = self.hass.async_create_task(self._try_get_state()) + self._get_state_task.add_done_callback(self.get_state_finished) + self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: @@ -256,11 +270,12 @@ class HomematicipHAP: self._ws_connection_closed.set() async def ws_reconnected_handler(self, reason: str) -> None: - """Handle websocket reconnection.""" + """Handle websocket reconnection. Is called when Websocket tries to reconnect.""" _LOGGER.info( - "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + "Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s", reason, ) + self._ws_connection_closed.set() async def get_hap( diff --git a/homeassistant/components/homematicip_cloud/icons.json b/homeassistant/components/homematicip_cloud/icons.json index 53a39d8213c..561ae79abc2 100644 --- a/homeassistant/components/homematicip_cloud/icons.json +++ b/homeassistant/components/homematicip_cloud/icons.json @@ -1,4 +1,15 @@ { + "entity": { + "sensor": { + "tilt_state": { + "state": { + "neutral": "mdi:garage", + "non_neutral": "mdi:garage-open", + "tilted": "mdi:garage-alert" + } + } + } + }, "services": { "activate_eco_mode_with_duration": { "service": "mdi:leaf" diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index d5175e6e647..1e602cd09c2 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -2,13 +2,20 @@ from __future__ import annotations +import logging from typing import Any -from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState +from homematicip.base.enums import ( + DeviceType, + FunctionalChannelType, + OpticalSignalBehaviour, + RGBColorState, +) from homematicip.base.functionalChannels import NotificationLightChannel from homematicip.device import ( BrandDimmer, BrandSwitchNotificationLight, + Device, Dimmer, DinRailDimmer3, FullFlushDimmer, @@ -34,6 +41,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP +_logger = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +52,14 @@ async def async_setup_entry( """Set up the HomematicIP Cloud lights from a config entry.""" hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] + + entities.extend( + HomematicipLightHS(hap, d, ch.index) + for d in hap.home.devices + for ch in d.functionalChannels + if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL + ) + for device in hap.home.devices: if ( isinstance(device, SwitchMeasuring) @@ -104,6 +121,64 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): 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} + + 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) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.functional_channel.on + + @property + def brightness(self) -> int | None: + """Return the current brightness.""" + return int(self.functional_channel.dimLevel * 255.0) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hue and saturation color value [float, float].""" + if ( + self.functional_channel.hue is None + or self.functional_channel.saturationLevel is None + ): + return None + return ( + self.functional_channel.hue, + self.functional_channel.saturationLevel * 100.0, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + + 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 = self.functional_channel.hue + saturation = self.functional_channel.saturationLevel + + if ATTR_BRIGHTNESS not in kwargs: + # If no brightness is set, use the current brightness + dim_level = self.functional_channel.dimLevel or 1.0 + + await self.functional_channel.set_hue_saturation_dim_level_async( + hue=hue, saturation_level=saturation, dim_level=dim_level + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.functional_channel.set_switch_state_async(on=False) + + class HomematicipLightMeasuring(HomematicipLight): """Representation of the HomematicIP measuring light.""" diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index d5af2859873..14b5ac39310 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.6"] + "requirements": ["homematicip==2.2.0"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 13f3694de7a..588e67bac95 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -11,6 +11,7 @@ from homematicip.base.functionalChannels import ( FunctionalChannel, ) from homematicip.device import ( + Device, EnergySensorsInterface, FloorTerminalBlock6, FloorTerminalBlock10, @@ -31,6 +32,8 @@ from homematicip.device import ( TemperatureHumiditySensorDisplay, TemperatureHumiditySensorOutdoor, TemperatureHumiditySensorWithoutDisplay, + TiltVibrationSensor, + WateringActuator, WeatherSensor, WeatherSensorPlus, WeatherSensorPro, @@ -43,7 +46,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + DEGREE, LIGHT_LUX, PERCENTAGE, UnitOfEnergy, @@ -62,6 +67,11 @@ from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP from .helpers import get_channels_from_device +ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" +ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle" +ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE = ( + "acceleration_sensor_second_trigger_angle" +) ATTR_CURRENT_ILLUMINATION = "current_illumination" ATTR_LOWEST_ILLUMINATION = "lowest_illumination" ATTR_HIGHEST_ILLUMINATION = "highest_illumination" @@ -89,6 +99,159 @@ ILLUMINATION_DEVICE_ATTRIBUTES = { "highestIllumination": ATTR_HIGHEST_ILLUMINATION, } +TILT_STATE_VALUES = ["neutral", "tilted", "non_neutral"] + + +def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]: + """Generate a mapping of device types to handler functions.""" + return { + HomeControlAccessPoint: lambda device: [ + HomematicipAccesspointDutyCycle(hap, device) + ], + HeatingThermostat: lambda device: [ + HomematicipHeatingThermostat(hap, device), + HomematicipTemperatureSensor(hap, device), + ], + HeatingThermostatCompact: lambda device: [ + HomematicipHeatingThermostat(hap, device), + HomematicipTemperatureSensor(hap, device), + ], + HeatingThermostatEvo: lambda device: [ + HomematicipHeatingThermostat(hap, device), + HomematicipTemperatureSensor(hap, device), + ], + TemperatureHumiditySensorDisplay: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + TemperatureHumiditySensorWithoutDisplay: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + TemperatureHumiditySensorOutdoor: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + RoomControlDeviceAnalog: lambda device: [ + HomematicipTemperatureSensor(hap, device), + ], + LightSensor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + MotionDetectorIndoor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + MotionDetectorOutdoor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + MotionDetectorPushButton: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + PresenceDetectorIndoor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + SwitchMeasuring: lambda device: [ + HomematicipPowerSensor(hap, device), + HomematicipEnergySensor(hap, device), + ], + PassageDetector: lambda device: [ + HomematicipPassageDetectorDeltaCounter(hap, device), + ], + TemperatureDifferenceSensor2: lambda device: [ + HomematicpTemperatureExternalSensorCh1(hap, device), + HomematicpTemperatureExternalSensorCh2(hap, device), + HomematicpTemperatureExternalSensorDelta(hap, device), + ], + TiltVibrationSensor: lambda device: [ + HomematicipTiltStateSensor(hap, device), + HomematicipTiltAngleSensor(hap, device), + ], + WateringActuator: lambda device: [ + entity + for ch in device.functionalChannels + if ch.functionalChannelType + == FunctionalChannelType.WATERING_ACTUATOR_CHANNEL + for entity in ( + HomematicipWaterFlowSensor( + hap, device, channel=ch.index, post="currentWaterFlow" + ), + HomematicipWaterVolumeSensor( + hap, + device, + channel=ch.index, + post="waterVolume", + attribute="waterVolume", + ), + HomematicipWaterVolumeSinceOpenSensor( + hap, + device, + channel=ch.index, + ), + ) + ], + WeatherSensor: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipIlluminanceSensor(hap, device), + HomematicipWindspeedSensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + WeatherSensorPlus: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipIlluminanceSensor(hap, device), + HomematicipWindspeedSensor(hap, device), + HomematicipTodayRainSensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + WeatherSensorPro: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipIlluminanceSensor(hap, device), + HomematicipWindspeedSensor(hap, device), + HomematicipTodayRainSensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + EnergySensorsInterface: lambda device: _handle_energy_sensor_interface( + hap, device + ), + } + + +def _handle_energy_sensor_interface( + hap: HomematicipHAP, device: Device +) -> list[HomematicipGenericEntity]: + """Handle energy sensor interface devices.""" + result: list[HomematicipGenericEntity] = [] + for ch in get_channels_from_device( + device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL + ): + if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_IEC: + if ch.currentPowerConsumption is not None: + result.append(HmipEsiIecPowerConsumption(hap, device)) + if ch.energyCounterOneType != ESI_TYPE_UNKNOWN: + result.append(HmipEsiIecEnergyCounterHighTariff(hap, device)) + if ch.energyCounterTwoType != ESI_TYPE_UNKNOWN: + result.append(HmipEsiIecEnergyCounterLowTariff(hap, device)) + if ch.energyCounterThreeType != ESI_TYPE_UNKNOWN: + result.append(HmipEsiIecEnergyCounterInputSingleTariff(hap, device)) + + if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_GAS: + if ch.currentGasFlow is not None: + result.append(HmipEsiGasCurrentGasFlow(hap, device)) + if ch.gasVolume is not None: + result.append(HmipEsiGasGasVolume(hap, device)) + + if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_LED: + if ch.currentPowerConsumption is not None: + result.append(HmipEsiLedCurrentPowerConsumption(hap, device)) + result.append(HmipEsiLedEnergyCounterHighTariff(hap, device)) + + return result + async def async_setup_entry( hass: HomeAssistant, @@ -98,109 +261,147 @@ async def async_setup_entry( """Set up the HomematicIP Cloud sensors from a config entry.""" hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] + + # Get device handlers dynamically + device_handlers = get_device_handlers(hap) + + # Process all devices for device in hap.home.devices: - if isinstance(device, HomeControlAccessPoint): - entities.append(HomematicipAccesspointDutyCycle(hap, device)) - if isinstance( - device, - ( - HeatingThermostat, - HeatingThermostatCompact, - HeatingThermostatEvo, - ), - ): - entities.append(HomematicipHeatingThermostat(hap, device)) - entities.append(HomematicipTemperatureSensor(hap, device)) - if isinstance( - device, - ( - TemperatureHumiditySensorDisplay, - TemperatureHumiditySensorWithoutDisplay, - TemperatureHumiditySensorOutdoor, - WeatherSensor, - WeatherSensorPlus, - WeatherSensorPro, - ), - ): - entities.append(HomematicipTemperatureSensor(hap, device)) - entities.append(HomematicipHumiditySensor(hap, device)) - entities.append(HomematicipAbsoluteHumiditySensor(hap, device)) - elif isinstance(device, (RoomControlDeviceAnalog,)): - entities.append(HomematicipTemperatureSensor(hap, device)) - if isinstance( - device, - ( - LightSensor, - MotionDetectorIndoor, - MotionDetectorOutdoor, - MotionDetectorPushButton, - PresenceDetectorIndoor, - WeatherSensor, - WeatherSensorPlus, - WeatherSensorPro, - ), - ): - entities.append(HomematicipIlluminanceSensor(hap, device)) - if isinstance(device, SwitchMeasuring): - entities.append(HomematicipPowerSensor(hap, device)) - entities.append(HomematicipEnergySensor(hap, device)) - if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): - entities.append(HomematicipWindspeedSensor(hap, device)) - if isinstance(device, (WeatherSensorPlus, WeatherSensorPro)): - entities.append(HomematicipTodayRainSensor(hap, device)) - if isinstance(device, PassageDetector): - entities.append(HomematicipPassageDetectorDeltaCounter(hap, device)) - if isinstance(device, TemperatureDifferenceSensor2): - entities.append(HomematicpTemperatureExternalSensorCh1(hap, device)) - entities.append(HomematicpTemperatureExternalSensorCh2(hap, device)) - entities.append(HomematicpTemperatureExternalSensorDelta(hap, device)) - if isinstance(device, EnergySensorsInterface): - for ch in get_channels_from_device( - device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL - ): - if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_IEC: - if ch.currentPowerConsumption is not None: - entities.append(HmipEsiIecPowerConsumption(hap, device)) - if ch.energyCounterOneType != ESI_TYPE_UNKNOWN: - entities.append(HmipEsiIecEnergyCounterHighTariff(hap, device)) - if ch.energyCounterTwoType != ESI_TYPE_UNKNOWN: - entities.append(HmipEsiIecEnergyCounterLowTariff(hap, device)) - if ch.energyCounterThreeType != ESI_TYPE_UNKNOWN: - entities.append( - HmipEsiIecEnergyCounterInputSingleTariff(hap, device) - ) + for device_class, handler in device_handlers.items(): + if isinstance(device, device_class): + entities.extend(handler(device)) - if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_GAS: - if ch.currentGasFlow is not None: - entities.append(HmipEsiGasCurrentGasFlow(hap, device)) - if ch.gasVolume is not None: - entities.append(HmipEsiGasGasVolume(hap, device)) - - if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_LED: - if ch.currentPowerConsumption is not None: - entities.append(HmipEsiLedCurrentPowerConsumption(hap, device)) - entities.append(HmipEsiLedEnergyCounterHighTariff(hap, device)) - if isinstance( - device, - ( - FloorTerminalBlock6, - FloorTerminalBlock10, - FloorTerminalBlock12, - WiredFloorTerminalBlock12, - ), - ): - entities.extend( - HomematicipFloorTerminalBlockMechanicChannelValve( - hap, device, channel=channel.index - ) - for channel in device.functionalChannels - if isinstance(channel, FloorTerminalBlockMechanicChannel) - and getattr(channel, "valvePosition", None) is not None - ) + # Handle floor terminal blocks separately + floor_terminal_blocks = ( + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + WiredFloorTerminalBlock12, + ) + entities.extend( + HomematicipFloorTerminalBlockMechanicChannelValve( + hap, device, channel=channel.index + ) + for device in hap.home.devices + if isinstance(device, floor_terminal_blocks) + for channel in device.functionalChannels + if isinstance(channel, FloorTerminalBlockMechanicChannel) + and getattr(channel, "valvePosition", None) is not None + ) async_add_entities(entities) +class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP watering flow sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolumeFlowRate.LITERS_PER_MINUTE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, hap: HomematicipHAP, device: Device, channel: int, post: str + ) -> None: + """Initialize the watering flow sensor device.""" + super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + + @property + def native_value(self) -> float | None: + """Return the state.""" + return self.functional_channel.waterFlow + + +class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP watering volume sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolume.LITERS + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__( + self, + hap: HomematicipHAP, + device: Device, + channel: int, + post: str, + attribute: str, + ) -> None: + """Initialize the watering volume sensor device.""" + super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + self._attribute_name = attribute + + @property + def native_value(self) -> float | None: + """Return the state.""" + return getattr(self.functional_channel, self._attribute_name, None) + + +class HomematicipWaterVolumeSinceOpenSensor(HomematicipWaterVolumeSensor): + """Representation of the HomematicIP watering volume since open sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolume.LITERS + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the watering flow volume since open device.""" + super().__init__( + hap, + device, + channel=channel, + post="waterVolumeSinceOpen", + attribute="waterVolumeSinceOpen", + ) + + +class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP tilt angle sensor.""" + + _attr_native_unit_of_measurement = DEGREE + _attr_state_class = SensorStateClass.MEASUREMENT_ANGLE + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the tilt angle sensor device.""" + super().__init__(hap, device, post="Tilt Angle") + + @property + def native_value(self) -> int | None: + """Return the state.""" + return getattr(self.functional_channel, "absoluteAngle", None) + + +class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP tilt sensor.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = TILT_STATE_VALUES + _attr_translation_key = "tilt_state" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the tilt sensor device.""" + super().__init__(hap, device, post="Tilt State") + + @property + def native_value(self) -> str | None: + """Return the state.""" + tilt_state = getattr(self.functional_channel, "tiltState", None) + return tilt_state.lower() if tilt_state is not None else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the tilt sensor.""" + state_attr = super().extra_state_attributes + + state_attr[ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION] = getattr( + self.functional_channel, "accelerationSensorNeutralPosition", None + ) + state_attr[ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE] = getattr( + self.functional_channel, "accelerationSensorTriggerAngle", None + ) + state_attr[ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE] = getattr( + self.functional_channel, "accelerationSensorSecondTriggerAngle", None + ) + + return state_attr + + class HomematicipFloorTerminalBlockMechanicChannelValve( HomematicipGenericEntity, SensorEntity ): @@ -342,7 +543,9 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP absolute humidity sensor.""" - _attr_native_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER + _attr_device_class = SensorDeviceClass.ABSOLUTE_HUMIDITY + _attr_native_unit_of_measurement = CONCENTRATION_GRAMS_PER_CUBIC_METER + _attr_suggested_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hap: HomematicipHAP, device) -> None: @@ -350,7 +553,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Absolute Humidity") @property - def native_value(self) -> int | None: + def native_value(self) -> float | None: """Return the state.""" if self.functional_channel is None: return None @@ -364,8 +567,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): ): return None - # Convert from g/m³ to mg/m³ - return int(float(value) * 1000) + return round(value, 3) class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 7b1b08ac4e2..bc170d5f0c3 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -27,6 +27,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "sensor": { + "tilt_state": { + "state": { + "neutral": "Neutral", + "non_neutral": "Non-neutral", + "tilted": "Tilted" + } + } + } + }, "exceptions": { "access_point_not_found": { "message": "No matching access point found for access point ID {id}" diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index ca591adbf5e..5da2989f93f 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -18,6 +18,9 @@ from homematicip.device import ( PrintedCircuitBoardSwitch2, PrintedCircuitBoardSwitchBattery, SwitchMeasuring, + WiredInput32, + WiredInputSwitch6, + WiredSwitch4, WiredSwitch8, ) from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup @@ -51,6 +54,7 @@ async def async_setup_entry( elif isinstance( device, ( + WiredSwitch4, WiredSwitch8, OpenCollector8Module, BrandSwitch2, @@ -60,6 +64,8 @@ async def async_setup_entry( MotionDetectorSwitchOutdoor, DinRailSwitch, DinRailSwitch4, + WiredInput32, + WiredInputSwitch6, ), ): channel_indices = [ diff --git a/homeassistant/components/homematicip_cloud/valve.py b/homeassistant/components/homematicip_cloud/valve.py new file mode 100644 index 00000000000..aaeaa3c565c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/valve.py @@ -0,0 +1,59 @@ +"""Support for HomematicIP Cloud valve devices.""" + +from homematicip.base.functionalChannels import FunctionalChannelType +from homematicip.device import Device + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import HomematicipGenericEntity +from .hap import HomematicIPConfigEntry, HomematicipHAP + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomematicIPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the HomematicIP valves from a config entry.""" + hap = config_entry.runtime_data + entities = [ + HomematicipWateringValve(hap, device, ch.index) + for device in hap.home.devices + for ch in device.functionalChannels + if ch.functionalChannelType == FunctionalChannelType.WATERING_ACTUATOR_CHANNEL + ] + + async_add_entities(entities) + + +class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity): + """Representation of a HomematicIP valve.""" + + _attr_reports_position = False + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_device_class = ValveDeviceClass.WATER + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the valve.""" + super().__init__( + hap, device=device, channel=channel, post="watering", is_multi_channel=True + ) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.functional_channel.set_watering_switch_state_async(True) + + async def async_close_valve(self) -> None: + """Close valve.""" + await self.functional_channel.set_watering_switch_state_async(False) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return self.functional_channel.wateringActive is False diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index eb89ba2a681..d270ffec72f 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -9,17 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import ( - async_create_clientsession, - async_get_clientsession, -) +from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import ( - _LOGGER, - CONF_COOL_AWAY_TEMPERATURE, - CONF_HEAT_AWAY_TEMPERATURE, - DOMAIN, -) +from .const import _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE UPDATE_LOOP_SLEEP_TIME = 5 PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH] @@ -56,11 +48,11 @@ async def async_setup_entry( username = config_entry.data[CONF_USERNAME] password = config_entry.data[CONF_PASSWORD] - if len(hass.config_entries.async_entries(DOMAIN)) > 1: - session = async_create_clientsession(hass) - else: - session = async_get_clientsession(hass) - + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. + session = async_create_clientsession(hass) client = aiosomecomfort.AIOSomeComfort(username, password, session=session) try: await client.login() @@ -91,18 +83,9 @@ async def async_setup_entry( config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - return True -async def update_listener( - hass: HomeAssistant, config_entry: HoneywellConfigEntry -) -> None: - """Update listener.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry ) -> bool: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index c7cda500692..c18bb0296aa 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -12,11 +12,11 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( CONF_COOL_AWAY_TEMPERATURE, @@ -114,10 +114,14 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): async def is_valid(self, **kwargs) -> bool: """Check if login credentials are valid.""" + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. client = aiosomecomfort.AIOSomeComfort( kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD], - session=async_get_clientsession(self.hass), + session=async_create_clientsession(self.hass), ) await client.login() @@ -132,7 +136,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): return HoneywellOptionsFlowHandler() -class HoneywellOptionsFlowHandler(OptionsFlow): +class HoneywellOptionsFlowHandler(OptionsFlowWithReload): """Config flow options for Honeywell.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 7fa102c6599..d2cd5a3c6a4 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.32"] + "requirements": ["AIOSomecomfort==0.0.33"] } diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 2c68223581a..ee844f320bc 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -29,7 +29,7 @@ "services": { "dismiss": { "name": "Dismiss", - "description": "Dismisses a html5 notification.", + "description": "Dismisses an HTML5 notification.", "fields": { "target": { "name": "Target", diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index cdf3347e24f..f048d571b9c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -37,12 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - frame, - issue_registry as ir, - storage, -) +from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, KEY_AUTHENTICATED, # noqa: F401 @@ -505,25 +500,6 @@ class HomeAssistantHTTP: ) ) - def register_static_path( - self, url_path: str, path: str, cache_headers: bool = True - ) -> None: - """Register a folder or file to serve as a static path.""" - frame.report_usage( - "calls hass.http.register_static_path which " - "does blocking I/O in the event loop, instead " - "call `await hass.http.async_register_static_paths(" - f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`', - exclude_integrations={"http"}, - core_behavior=frame.ReportBehavior.ERROR, - core_integration_behavior=frame.ReportBehavior.ERROR, - custom_integration_behavior=frame.ReportBehavior.ERROR, - breaks_in_ha_version="2025.7", - ) - configs = [StaticPathConfig(url_path, path, cache_headers)] - resources = self._make_static_resources(configs) - self._async_register_static_paths(configs, resources) - def _create_ssl_context(self) -> ssl.SSLContext | None: context: ssl.SSLContext | None = None assert self.ssl_certificate is not None diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7e00cc70eaa..227ee074439 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -223,7 +223,7 @@ async def async_setup_auth( # We first start with a string check to avoid parsing query params # for every request. elif ( - request.method == "GET" + request.method in ["GET", "HEAD"] and SIGN_QUERY_PARAM in request.query_string and async_validate_signed_request(request) ): diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 6126968eab6..a7bd90baefd 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -8,7 +8,6 @@ from contextlib import suppress from dataclasses import dataclass, field from datetime import timedelta import logging -import time from typing import Any, NamedTuple, cast from xml.parsers.expat import ExpatError @@ -25,6 +24,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, ATTR_HW_VERSION, ATTR_MODEL, ATTR_SW_VERSION, @@ -55,9 +55,9 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ADMIN_SERVICES, ALL_KEYS, - ATTR_CONFIG_ENTRY_ID, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, + CONF_UPNP_UDN, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_MANUFACTURER, @@ -78,7 +78,6 @@ from .const import ( KEY_WLAN_HOST_LIST, KEY_WLAN_WIFI_FEATURE_SWITCH, KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH, - NOTIFY_SUPPRESS_TIMEOUT, SERVICE_RESUME_INTEGRATION, SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, @@ -124,7 +123,6 @@ class Router: inflight_gets: set[str] = field(default_factory=set, init=False) client: Client = field(init=False) suspended: bool = field(default=False, init=False) - notify_last_attempt: float = field(default=-1, init=False) def __post_init__(self) -> None: """Set up internal state on init.""" @@ -150,9 +148,12 @@ class Router: @property def device_connections(self) -> set[tuple[str, str]]: """Get router connections for device registry.""" - return { + connections = { (dr.CONNECTION_NETWORK_MAC, x) for x in self.config_entry.data[CONF_MAC] } + if udn := self.config_entry.data.get(CONF_UPNP_UDN): + connections.add((dr.CONNECTION_UPNP, udn)) + return connections def _get_data(self, key: str, func: Callable[[], Any]) -> None: if not self.subscriptions.get(key): @@ -195,19 +196,6 @@ class Router: key, ) self.subscriptions.pop(key) - except Timeout: - grace_left = ( - self.notify_last_attempt - time.monotonic() + NOTIFY_SUPPRESS_TIMEOUT - ) - if grace_left > 0: - _LOGGER.debug( - "%s timed out, %.1fs notify timeout suppress grace remaining", - key, - grace_left, - exc_info=True, - ) - else: - raise finally: self.inflight_gets.discard(key) _LOGGER.debug("%s=%s", key, self.data.get(key)) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 88167fab4b9..002f19bc9e0 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -51,6 +51,7 @@ from .const import ( CONF_MANUFACTURER, CONF_TRACK_WIRED_CLIENTS, CONF_UNAUTHENTICATED_MODE, + CONF_UPNP_UDN, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME, @@ -63,21 +64,22 @@ from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) -class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle Huawei LTE config flow.""" +class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): + """Huawei LTE config flow.""" VERSION = 3 manufacturer: str | None = None + upnp_udn: str | None = None url: str | None = None @staticmethod @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlowHandler: + ) -> HuaweiLteOptionsFlow: """Get options flow.""" - return OptionsFlowHandler() + return HuaweiLteOptionsFlow() async def _async_show_user_form( self, @@ -250,6 +252,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): { CONF_MAC: get_device_macs(info, wlan_settings), CONF_MANUFACTURER: self.manufacturer, + CONF_UPNP_UDN: self.upnp_udn, } ) @@ -284,11 +287,12 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): # url_normalize only returns None if passed None, and we don't do that assert url is not None - unique_id = discovery_info.upnp.get( - ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN] - ) + upnp_udn = discovery_info.upnp.get(ATTR_UPNP_UDN) + unique_id = discovery_info.upnp.get(ATTR_UPNP_SERIAL, upnp_udn) await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured(updates={CONF_URL: url}) + self._abort_if_unique_id_configured( + updates={CONF_UPNP_UDN: upnp_udn, CONF_URL: url} + ) def _is_supported_device() -> bool: """See if we are looking at a possibly supported device. @@ -319,6 +323,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): } ) self.manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) + self.upnp_udn = upnp_udn self.url = url return await self._async_show_user_form() @@ -354,7 +359,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort(entry, data=new_data) -class OptionsFlowHandler(OptionsFlow): +class HuaweiLteOptionsFlow(OptionsFlow): """Huawei LTE options flow.""" async def async_step_init( diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index af9bfd330e9..bc114f56e99 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -2,11 +2,10 @@ DOMAIN = "huawei_lte" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" - CONF_MANUFACTURER = "manufacturer" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" +CONF_UPNP_UDN = "upnp_udn" DEFAULT_DEVICE_NAME = "LTE" DEFAULT_MANUFACTURER = "Huawei Technologies Co., Ltd." @@ -17,7 +16,6 @@ DEFAULT_UNAUTHENTICATED_MODE = False UPDATE_SIGNAL = f"{DOMAIN}_update" CONNECTION_TIMEOUT = 10 -NOTIFY_SUPPRESS_TIMEOUT = 30 SERVICE_RESUME_INTEGRATION = "resume_integration" SERVICE_SUSPEND_INTEGRATION = "suspend_integration" diff --git a/homeassistant/components/huawei_lte/diagnostics.py b/homeassistant/components/huawei_lte/diagnostics.py new file mode 100644 index 00000000000..975ab476e6c --- /dev/null +++ b/homeassistant/components/huawei_lte/diagnostics.py @@ -0,0 +1,86 @@ +"""Diagnostics support for Huawei LTE.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +ENTRY_FIELDS_DATA_TO_REDACT = { + "mac", + "username", + "password", +} +DEVICE_INFORMATION_DATA_TO_REDACT = { + "SerialNumber", + "Imei", + "Imsi", + "Iccid", + "Msisdn", + "MacAddress1", + "MacAddress2", + "WanIPAddress", + "wan_dns_address", + "WanIPv6Address", + "wan_ipv6_dns_address", + "Mccmnc", + "WifiMacAddrWl0", + "WifiMacAddrWl1", +} +DEVICE_SIGNAL_DATA_TO_REDACT = { + "pci", + "cell_id", + "enodeb_id", + "rac", + "lac", + "tac", + "nei_cellid", + "plmn", + "bsic", +} +MONITORING_STATUS_DATA_TO_REDACT = { + "PrimaryDns", + "SecondaryDns", + "PrimaryIPv6Dns", + "SecondaryIPv6Dns", +} +NET_CURRENT_PLMN_DATA_TO_REDACT = { + "net_current_plmn", +} +LAN_HOST_INFO_DATA_TO_REDACT = { + "lan_host_info", +} +WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT = { + "Ssid", + "WifiSsid", +} +WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT = { + "WifiMac", +} +TO_REDACT = { + *ENTRY_FIELDS_DATA_TO_REDACT, + *DEVICE_INFORMATION_DATA_TO_REDACT, + *DEVICE_SIGNAL_DATA_TO_REDACT, + *MONITORING_STATUS_DATA_TO_REDACT, + *NET_CURRENT_PLMN_DATA_TO_REDACT, + *LAN_HOST_INFO_DATA_TO_REDACT, + *WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT, + *WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "entry": entry.data, + "router": hass.data[DOMAIN].routers[entry.entry_id].data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index fc154de3811..7543eb71d88 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -3,18 +3,17 @@ from __future__ import annotations import logging -import time from typing import Any from huawei_lte_api.exceptions import ResponseErrorException from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import Router -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -62,5 +61,3 @@ class HuaweiLteSmsNotificationService(BaseNotificationService): _LOGGER.debug("Sent to %s: %s", targets, resp) except ResponseErrorException as ex: _LOGGER.error("Could not send to %s: %s", targets, ex) - finally: - self.router.notify_last_attempt = time.monotonic() diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index f26b11707c2..ec6f3099679 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -77,7 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: identifiers={(DOMAIN, api.config.bridge_id)}, manufacturer="Signify", name=api.config.name, - model=api.config.model_id, + model_id=api.config.model_id, sw_version=api.config.software_version, ) # create persistent notification if we found a bridge version with security vulnerability @@ -105,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: }, manufacturer=api.config.bridge_device.product_data.manufacturer_name, name=api.config.name, - model=api.config.model_id, + model_id=api.config.model_id, sw_version=api.config.software_version, ) diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index b7251382296..36dfdd423ef 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -163,6 +163,7 @@ async def async_setup_entry( name="light", update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update), update_interval=SCAN_INTERVAL, + config_entry=config_entry, request_refresh_debouncer=Debouncer( bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True ), @@ -197,6 +198,7 @@ async def async_setup_entry( name="group", update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update), update_interval=SCAN_INTERVAL, + config_entry=config_entry, request_refresh_debouncer=Debouncer( bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True ), diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index 393069b0c7c..fb8f3c572c1 100644 --- a/homeassistant/components/hue/v1/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -53,6 +53,7 @@ class SensorManager: LOGGER, name="sensor", update_method=self.async_update_data, + config_entry=bridge.config_entry, update_interval=self.SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 7bb3d28e962..8979befcf73 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_MODEL_ID, ATTR_NAME, ATTR_SUGGESTED_AREA, ATTR_SW_VERSION, @@ -55,12 +56,12 @@ async def async_setup_devices(bridge: HueBridge): else None, ) # Register a Hue device resource as device in HA device registry. - model = f"{hue_resource.product_data.product_name} ({hue_resource.product_data.model_id})" params = { ATTR_IDENTIFIERS: {(DOMAIN, hue_resource.id)}, ATTR_SW_VERSION: hue_resource.product_data.software_version, ATTR_NAME: hue_resource.metadata.name, - ATTR_MODEL: model, + ATTR_MODEL: hue_resource.product_data.product_name, + ATTR_MODEL_ID: hue_resource.product_data.model_id, ATTR_MANUFACTURER: hue_resource.product_data.manufacturer_name, } if room := dev_controller.get_room(hue_resource.id): diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 3e9ff8727ce..89624a0efbc 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -11,9 +11,9 @@ from aiopvapi.shades import Shades from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DOMAIN, HUB_EXCEPTIONS +from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewConfigEntry, PowerviewEntryData from .shade_data import PowerviewShadeData @@ -64,6 +64,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerviewConfigEntry) -> ) return False + # manual registration of the hub + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, hub.mac_address)}, + identifiers={(DOMAIN, hub.serial_number)}, + manufacturer=MANUFACTURER, + name=hub.name, + model=hub.model, + sw_version=hub.firmware, + hw_version=hub.main_processor_version.name, + ) + try: rooms = Rooms(pv_request) room_data: PowerviewData = await rooms.get_rooms() diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 1945647a706..02adbc4adb6 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -21,6 +21,7 @@ PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CALENDAR, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.LAWN_MOWER, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 1f7ed7127e0..b39f2138ab4 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -14,17 +14,27 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator -from .entity import ( - AutomowerAvailableEntity, - _check_error_free, - handle_sending_exception, -) +from .entity import AutomowerControlEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 +async def async_reset_cutting_blade_usage_time( + session: AutomowerSession, + mower_id: str, +) -> None: + """Reset cutting blade usage time.""" + await session.commands.reset_cutting_blade_usage_time(mower_id) + + +def reset_cutting_blade_usage_time_availability(data: MowerAttributes) -> bool: + """Return True if blade usage time is greater than 0.""" + value = data.statistics.cutting_blade_usage_time + return value is not None and value > 0 + + @dataclass(frozen=True, kw_only=True) class AutomowerButtonEntityDescription(ButtonEntityDescription): """Describes Automower button entities.""" @@ -32,6 +42,7 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription): available_fn: Callable[[MowerAttributes], bool] = lambda _: True exists_fn: Callable[[MowerAttributes], bool] = lambda _: True press_fn: Callable[[AutomowerSession, str], Awaitable[Any]] + poll_after_sending: bool = False MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( @@ -45,9 +56,16 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( AutomowerButtonEntityDescription( key="sync_clock", translation_key="sync_clock", - available_fn=_check_error_free, press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), ), + AutomowerButtonEntityDescription( + key="reset_cutting_blade_usage_time", + translation_key="reset_cutting_blade_usage_time", + available_fn=reset_cutting_blade_usage_time_availability, + exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, + press_fn=async_reset_cutting_blade_usage_time, + poll_after_sending=True, + ), ) @@ -71,7 +89,7 @@ async def async_setup_entry( _async_add_new_devices(set(coordinator.data)) -class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): +class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): """Defining the AutomowerButtonEntity.""" entity_description: AutomowerButtonEntityDescription @@ -90,9 +108,13 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): @property def available(self) -> bool: """Return the available attribute of the entity.""" - return self.entity_description.available_fn(self.mower_attributes) + return super().available and self.entity_description.available_fn( + self.mower_attributes + ) @handle_sending_exception() async def async_press(self) -> None: """Send a command to the mower.""" await self.entity_description.press_fn(self.coordinator.api, self.mower_id) + if self.entity_description.poll_after_sending: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index 26e939ec7d9..ac7447bc3c0 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -2,15 +2,18 @@ from datetime import datetime import logging +from typing import TYPE_CHECKING from aioautomower.model import make_name_string from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import AutomowerConfigEntry +from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity @@ -51,13 +54,27 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): self._attr_unique_id = mower_id self._event: CalendarEvent | None = None + @property + def device_name(self) -> str: + """Return the prefix for the event summary.""" + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, self.mower_id)} + ) + if TYPE_CHECKING: + assert device_entry is not None + assert device_entry.name is not None + + return device_entry.name_by_user or device_entry.name + @property def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" + if not self.available: + return None schedule = self.mower_attributes.calendar cursor = schedule.timeline.active_after(dt_util.now()) program_event = next(cursor, None) - _LOGGER.debug("program_event %s", program_event) if not program_event: return None work_area_name = None @@ -66,7 +83,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): program_event.work_area_id ] return CalendarEvent( - summary=make_name_string(work_area_name, program_event.schedule_no), + summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}", start=program_event.start, end=program_event.end, rrule=program_event.rrule_str, @@ -79,6 +96,8 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): This is only called when opening the calendar in the UI. """ + if not self.available: + return [] schedule = self.mower_attributes.calendar cursor = schedule.timeline.overlapping( start_date, @@ -93,7 +112,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): ] calendar_events.append( CalendarEvent( - summary=make_name_string(work_area_name, program_event.schedule_no), + summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}", start=program_event.start.replace(tzinfo=start_date.tzinfo), end=program_event.end.replace(tzinfo=start_date.tzinfo), rrule=program_event.rrule_str, diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py index 1ea0511d721..f50c03e1b53 100644 --- a/homeassistant/components/husqvarna_automower/const.py +++ b/homeassistant/components/husqvarna_automower/const.py @@ -1,7 +1,144 @@ """The constants for the Husqvarna Automower integration.""" +from aioautomower.model import MowerStates + DOMAIN = "husqvarna_automower" EXECUTION_TIME_DELAY = 5 NAME = "Husqvarna Automower" OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" + +ERROR_STATES = [ + MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, + MowerStates.FATAL_ERROR, + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] + +ERROR_KEYS = [ + "alarm_mower_in_motion", + "alarm_mower_lifted", + "alarm_mower_stopped", + "alarm_mower_switched_off", + "alarm_mower_tilted", + "alarm_outside_geofence", + "angular_sensor_problem", + "battery_problem", + "battery_restriction_due_to_ambient_temperature", + "can_error", + "charging_current_too_high", + "charging_station_blocked", + "charging_system_problem", + "collision_sensor_defect", + "collision_sensor_error", + "collision_sensor_problem_front", + "collision_sensor_problem_rear", + "com_board_not_available", + "communication_circuit_board_sw_must_be_updated", + "complex_working_area", + "connection_changed", + "connection_not_changed", + "connectivity_problem", + "connectivity_settings_restored", + "cutting_drive_motor_1_defect", + "cutting_drive_motor_2_defect", + "cutting_drive_motor_3_defect", + "cutting_height_blocked", + "cutting_height_problem", + "cutting_height_problem_curr", + "cutting_height_problem_dir", + "cutting_height_problem_drive", + "cutting_motor_problem", + "cutting_stopped_slope_too_steep", + "cutting_system_blocked", + "cutting_system_imbalance_warning", + "cutting_system_major_imbalance", + "destination_not_reachable", + "difficult_finding_home", + "docking_sensor_defect", + "electronic_problem", + "empty_battery", + "folding_cutting_deck_sensor_defect", + "folding_sensor_activated", + "geofence_problem", + "gps_navigation_problem", + "guide_1_not_found", + "guide_2_not_found", + "guide_3_not_found", + "guide_calibration_accomplished", + "guide_calibration_failed", + "high_charging_power_loss", + "high_internal_power_loss", + "high_internal_temperature", + "internal_voltage_error", + "invalid_battery_combination_invalid_combination_of_different_battery_types", + "invalid_sub_device_combination", + "invalid_system_configuration", + "left_brush_motor_overloaded", + "lift_sensor_defect", + "lifted", + "limited_cutting_height_range", + "loop_sensor_defect", + "loop_sensor_problem_front", + "loop_sensor_problem_left", + "loop_sensor_problem_rear", + "loop_sensor_problem_right", + "low_battery", + "memory_circuit_problem", + "mower_lifted", + "mower_tilted", + "no_accurate_position_from_satellites", + "no_confirmed_position", + "no_drive", + "no_loop_signal", + "no_power_in_charging_station", + "no_response_from_charger", + "outside_working_area", + "poor_signal_quality", + "reference_station_communication_problem", + "right_brush_motor_overloaded", + "safety_function_faulty", + "settings_restored", + "sim_card_locked", + "sim_card_not_found", + "sim_card_requires_pin", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", + "slope_too_steep", + "sms_could_not_be_sent", + "stop_button_problem", + "stuck_in_charging_station", + "switch_cord_problem", + "temporary_battery_problem", + "tilt_sensor_problem", + "too_high_discharge_current", + "too_high_internal_current", + "trapped", + "ultrasonic_problem", + "ultrasonic_sensor_1_defect", + "ultrasonic_sensor_2_defect", + "ultrasonic_sensor_3_defect", + "ultrasonic_sensor_4_defect", + "unexpected_cutting_height_adj", + "unexpected_error", + "upside_down", + "weak_gps_signal", + "wheel_drive_problem_left", + "wheel_drive_problem_rear_left", + "wheel_drive_problem_rear_right", + "wheel_drive_problem_right", + "wheel_motor_blocked_left", + "wheel_motor_blocked_rear_left", + "wheel_motor_blocked_rear_right", + "wheel_motor_blocked_right", + "wheel_motor_overloaded_left", + "wheel_motor_overloaded_rear_left", + "wheel_motor_overloaded_rear_right", + "wheel_motor_overloaded_right", + "work_area_not_valid", + "wrong_loop_signal", + "wrong_pin_code", + "zone_generator_problem", +] diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index dc653d8ce80..dc35c47ff4a 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -4,16 +4,18 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta import logging +from typing import override from aioautomower.exceptions import ( ApiError, AuthError, HusqvarnaTimeoutError, + HusqvarnaWSClientError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerDictionary +from aioautomower.model import MowerDictionary, MowerStates from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry @@ -28,7 +30,9 @@ _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time - +PONG_TIMEOUT = timedelta(seconds=90) +PING_INTERVAL = timedelta(seconds=10) +PING_TIMEOUT = timedelta(seconds=5) type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] @@ -57,39 +61,105 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] - self._devices_last_update: set[str] = set() - self._zones_last_update: dict[str, set[str]] = {} - self._areas_last_update: dict[str, set[int]] = {} + self.pong: datetime | None = None + self.websocket_alive: bool = False + self._watchdog_task: asyncio.Task | None = None - def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None: - """Add/remove devices and dynamic entities, when amount of devices changed.""" - self._async_add_remove_devices(data) - for mower_id in data: - if data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones(data) - if data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas(data) + @override + @callback + def async_update_listeners(self) -> None: + self._on_data_update() + super().async_update_listeners() async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" if not self.ws_connected: await self.api.connect() - self.api.register_data_callback(self.callback) + self.api.register_data_callback(self.handle_websocket_updates) self.ws_connected = True + + def start_watchdog() -> None: + if self._watchdog_task is not None and not self._watchdog_task.done(): + _LOGGER.debug("Cancelling previous watchdog task") + self._watchdog_task.cancel() + self._watchdog_task = self.config_entry.async_create_background_task( + self.hass, + self._pong_watchdog(), + "websocket_watchdog", + ) + + self.api.register_ws_ready_callback(start_watchdog) try: data = await self.api.get_status() except ApiError as err: raise UpdateFailed(err) from err except AuthError as err: raise ConfigEntryAuthFailed(err) from err - self._async_add_remove_devices_and_entities(data) return data @callback - def callback(self, ws_data: MowerDictionary) -> None: + def _on_data_update(self) -> None: + """Handle data updates and process dynamic entity management.""" + if self.data is not None: + self._async_add_remove_devices() + if any( + mower_data.capabilities.stay_out_zones + for mower_data in self.data.values() + ): + self._async_add_remove_stay_out_zones() + if any( + mower_data.capabilities.work_areas for mower_data in self.data.values() + ): + self._async_add_remove_work_areas() + if ( + not self._should_poll() + and self.update_interval is not None + and self.websocket_alive + ): + _LOGGER.debug("All mowers inactive and websocket alive: stop polling") + self.update_interval = None + if self.update_interval is None and self._should_poll(): + _LOGGER.debug( + "Polling re-enabled via WebSocket: at least one mower active" + ) + self.update_interval = SCAN_INTERVAL + self.hass.async_create_task(self.async_request_refresh()) + + @callback + def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" + self.hass.async_create_task(self._process_websocket_update(ws_data)) + + async def _process_websocket_update(self, ws_data: MowerDictionary) -> None: + """Handle incoming websocket update and update coordinator data.""" + for data in ws_data.values(): + existing_areas = data.work_areas or {} + for task in data.calendar.tasks: + work_area_id = task.work_area_id + if work_area_id is not None and work_area_id not in existing_areas: + _LOGGER.debug( + "New work area %s detected, refreshing data", work_area_id + ) + await self.async_request_refresh() + return + self.async_set_updated_data(ws_data) - self._async_add_remove_devices_and_entities(ws_data) + + @callback + def async_set_updated_data(self, data: MowerDictionary) -> None: + """Override DataUpdateCoordinator to preserve fixed polling interval. + + The built-in implementation resets the polling timer on every websocket + update. Since websockets do not deliver all required data (e.g. statistics + or work area details), we enforce a constant REST polling cadence. + """ + self.data = data + self.last_update_success = True + self.logger.debug( + "Manually updated %s data", + self.name, + ) + self.async_update_listeners() async def client_listen( self, @@ -103,7 +173,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): # Reset reconnect time after successful connection self.reconnect_time = DEFAULT_RECONNECT_TIME await automower_client.start_listening() - except HusqvarnaWSServerHandshakeError as err: + except (HusqvarnaWSServerHandshakeError, HusqvarnaWSClientError) as err: _LOGGER.debug( "Failed to connect to websocket. Trying to reconnect: %s", err, @@ -122,134 +192,143 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): "reconnect_task", ) - def _async_add_remove_devices(self, data: MowerDictionary) -> None: - """Add new device, remove non-existing device.""" - current_devices = set(data) + def _should_poll(self) -> bool: + """Return True if at least one mower is connected and at least one is not OFF.""" + return any(mower.metadata.connected for mower in self.data.values()) and any( + mower.mower.state != MowerStates.OFF for mower in self.data.values() + ) - # Skip update if no changes - if current_devices == self._devices_last_update: - return + async def _pong_watchdog(self) -> None: + _LOGGER.debug("Watchdog started") + try: + while True: + _LOGGER.debug("Sending ping") + self.websocket_alive = await self.api.send_empty_message() + _LOGGER.debug("Ping result: %s", self.websocket_alive) - # Process removed devices - removed_devices = self._devices_last_update - current_devices - if removed_devices: - _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices))) - self._remove_device(removed_devices) + await asyncio.sleep(60) + _LOGGER.debug("Websocket alive %s", self.websocket_alive) + if not self.websocket_alive: + _LOGGER.debug("No pong received → restart polling") + if self.update_interval is None: + self.update_interval = SCAN_INTERVAL + await self.async_request_refresh() + except asyncio.CancelledError: + _LOGGER.debug("Watchdog cancelled") - # Process new device - new_devices = current_devices - self._devices_last_update - if new_devices: - self.data = data - _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) - self._add_new_devices(new_devices) - - # Update device state - self._devices_last_update = current_devices - - def _remove_device(self, removed_devices: set[str]) -> None: - """Remove device from the registry.""" + def _async_add_remove_devices(self) -> None: + """Add new devices and remove orphaned devices from the registry.""" + current_devices = set(self.data) device_registry = dr.async_get(self.hass) - for mower_id in removed_devices: - if device := device_registry.async_get_device( - identifiers={(DOMAIN, str(mower_id))} - ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - def _add_new_devices(self, new_devices: set[str]) -> None: - """Add new device and trigger callbacks.""" - for mower_callback in self.new_devices_callbacks: - mower_callback(new_devices) + registered_devices: set[str] = { + str(mower_id) + for device in device_registry.devices.get_devices_for_config_entry_id( + self.config_entry.entry_id + ) + for domain, mower_id in device.identifiers + if domain == DOMAIN + } - def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: + orphaned_devices = registered_devices - current_devices + if orphaned_devices: + _LOGGER.debug("Removing orphaned devices: %s", orphaned_devices) + device_registry = dr.async_get(self.hass) + for mower_id in orphaned_devices: + dev = device_registry.async_get_device(identifiers={(DOMAIN, mower_id)}) + if dev is not None: + device_registry.async_update_device( + device_id=dev.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + new_devices = current_devices - registered_devices + if new_devices: + _LOGGER.debug("New devices found: %s", new_devices) + for mower_callback in self.new_devices_callbacks: + mower_callback(new_devices) + + def _async_add_remove_stay_out_zones(self) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" current_zones = { mower_id: set(mower_data.stay_out_zones.zones) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.stay_out_zones and mower_data.stay_out_zones is not None } - if not self._zones_last_update: - self._zones_last_update = current_zones - return - - if current_zones == self._zones_last_update: - return - - self._zones_last_update = self._update_stay_out_zones(current_zones) - - def _update_stay_out_zones( - self, current_zones: dict[str, set[str]] - ) -> dict[str, set[str]]: - """Update stay-out zones by adding and removing as needed.""" - new_zones = { - mower_id: zones - self._zones_last_update.get(mower_id, set()) - for mower_id, zones in current_zones.items() - } - removed_zones = { - mower_id: self._zones_last_update.get(mower_id, set()) - zones - for mower_id, zones in current_zones.items() - } - - for mower_id, zones in new_zones.items(): - for zone_callback in self.new_zones_callbacks: - zone_callback(mower_id, set(zones)) - entity_registry = er.async_get(self.hass) - for mower_id, zones in removed_zones.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for zone in zones: - if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"): - entity_registry.async_remove(entity_entry.entity_id) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - return current_zones + registered_zones: dict[str, set[str]] = {} + for mower_id in self.data: + registered_zones[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_stay_out_zones"): + zone_id = uid.removeprefix(f"{mower_id}_").removesuffix( + "_stay_out_zones" + ) + registered_zones[mower_id].add(zone_id) - def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: + for mower_id, current_ids in current_zones.items(): + known_ids = registered_zones.get(mower_id, set()) + + new_zones = current_ids - known_ids + removed_zones = known_ids - current_ids + + if new_zones: + _LOGGER.debug("New stay-out zones: %s", new_zones) + for zone_callback in self.new_zones_callbacks: + zone_callback(mower_id, new_zones) + + if removed_zones: + _LOGGER.debug("Removing stay-out zones: %s", removed_zones) + for entry in entries: + for zone_id in removed_zones: + if entry.unique_id == f"{mower_id}_{zone_id}_stay_out_zones": + entity_registry.async_remove(entry.entity_id) + + def _async_add_remove_work_areas(self) -> None: """Add new work areas, remove non-existing work areas.""" current_areas = { mower_id: set(mower_data.work_areas) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.work_areas and mower_data.work_areas is not None } - if not self._areas_last_update: - self._areas_last_update = current_areas - return - - if current_areas == self._areas_last_update: - return - - self._areas_last_update = self._update_work_areas(current_areas) - - def _update_work_areas( - self, current_areas: dict[str, set[int]] - ) -> dict[str, set[int]]: - """Update work areas by adding and removing as needed.""" - new_areas = { - mower_id: areas - self._areas_last_update.get(mower_id, set()) - for mower_id, areas in current_areas.items() - } - removed_areas = { - mower_id: self._areas_last_update.get(mower_id, set()) - areas - for mower_id, areas in current_areas.items() - } - - for mower_id, areas in new_areas.items(): - for area_callback in self.new_areas_callbacks: - area_callback(mower_id, set(areas)) - entity_registry = er.async_get(self.hass) - for mower_id, areas in removed_areas.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for area in areas: - if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"): - entity_registry.async_remove(entity_entry.entity_id) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - return current_areas + registered_areas: dict[str, set[int]] = {} + for mower_id in self.data: + registered_areas[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_work_area"): + parts = uid.removeprefix(f"{mower_id}_").split("_") + area_id_str = parts[0] if parts else None + if area_id_str and area_id_str.isdigit(): + registered_areas[mower_id].add(int(area_id_str)) + + for mower_id, current_ids in current_areas.items(): + known_ids = registered_areas.get(mower_id, set()) + + new_areas = current_ids - known_ids + removed_areas = known_ids - current_ids + + if new_areas: + _LOGGER.debug("New work areas: %s", new_areas) + for area_callback in self.new_areas_callbacks: + area_callback(mower_id, new_areas) + + if removed_areas: + _LOGGER.debug("Removing work areas: %s", removed_areas) + for entry in entries: + for area_id in removed_areas: + if entry.unique_id.startswith(f"{mower_id}_{area_id}_"): + entity_registry.async_remove(entry.entity_id) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 150a3d18d87..99df51c7fe7 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -37,15 +37,6 @@ ERROR_STATES = [ ] -@callback -def _check_error_free(mower_attributes: MowerAttributes) -> bool: - """Check if the mower has any errors.""" - return ( - mower_attributes.mower.state not in ERROR_STATES - or mower_attributes.mower.activity not in ERROR_ACTIVITIES - ) - - @callback def _work_area_translation_key(work_area_id: int, key: str) -> str: """Return the translation key.""" @@ -114,26 +105,26 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): """Get the mower attributes of the current mower.""" return self.coordinator.data[self.mower_id] + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_id in self.coordinator.data -class AutomowerAvailableEntity(AutomowerBaseEntity): + +class AutomowerControlEntity(AutomowerBaseEntity): """Replies available when the mower is connected.""" @property def available(self) -> bool: """Return True if the device is available.""" - return super().available and self.mower_attributes.metadata.connected + return ( + super().available + and self.mower_attributes.metadata.connected + and self.mower_attributes.mower.state != MowerStates.OFF + ) -class AutomowerControlEntity(AutomowerAvailableEntity): - """Replies available when the mower is connected and not in error state.""" - - @property - def available(self) -> bool: - """Return True if the device is available.""" - return super().available and _check_error_free(self.mower_attributes) - - -class WorkAreaAvailableEntity(AutomowerAvailableEntity): +class WorkAreaAvailableEntity(AutomowerControlEntity): """Base entity for work areas.""" def __init__( diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py new file mode 100644 index 00000000000..8e2e48b940d --- /dev/null +++ b/homeassistant/components/husqvarna_automower/event.py @@ -0,0 +1,108 @@ +"""Creates the event entities for supported mowers.""" + +from collections.abc import Callable + +from aioautomower.model import SingleMessageData + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AutomowerConfigEntry +from .const import ERROR_KEYS +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +PARALLEL_UPDATES = 1 + +ATTR_SEVERITY = "severity" +ATTR_LATITUDE = "latitude" +ATTR_LONGITUDE = "longitude" +ATTR_DATE_TIME = "date_time" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AutomowerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Automower message event entities. + + Entities are created dynamically based on messages received from the API, + but only for mowers that support message events. + """ + coordinator = config_entry.runtime_data + entity_registry = er.async_get(hass) + + restored_mowers = { + entry.unique_id.removesuffix("_message") + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entry.domain == EVENT_DOMAIN + } + + async_add_entities( + AutomowerMessageEventEntity(mower_id, coordinator) + for mower_id in restored_mowers + if mower_id in coordinator.data + ) + + @callback + def _handle_message(msg: SingleMessageData) -> None: + if msg.id in restored_mowers: + return + + restored_mowers.add(msg.id) + async_add_entities([AutomowerMessageEventEntity(msg.id, coordinator)]) + + coordinator.api.register_single_message_callback(_handle_message) + + +class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity): + """EventEntity for Automower message events.""" + + entity_description: EventEntityDescription + _message_cb: Callable[[SingleMessageData], None] + _attr_translation_key = "message" + _attr_event_types = ERROR_KEYS + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Initialize Automower message event entity.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = f"{mower_id}_message" + + @callback + def _handle(self, msg: SingleMessageData) -> None: + """Handle a message event from the API and trigger the event entity if it matches the entity's mower ID.""" + if msg.id != self.mower_id: + return + message = msg.attributes.message + self._trigger_event( + message.code, + { + ATTR_SEVERITY: message.severity, + ATTR_LATITUDE: message.latitude, + ATTR_LONGITUDE: message.longitude, + ATTR_DATE_TIME: message.time, + }, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callback when entity is added to hass.""" + await super().async_added_to_hass() + self.coordinator.api.register_single_message_callback(self._handle) + + async def async_will_remove_from_hass(self) -> None: + """Unregister WebSocket callback when entity is removed.""" + self.coordinator.api.unregister_single_message_callback(self._handle) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index 14ac5ce4068..ba9bc82f156 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -3,14 +3,19 @@ "binary_sensor": { "leaving_dock": { "default": "mdi:debug-step-out" - }, - "returning_to_dock": { - "default": "mdi:debug-step-into" } }, "button": { "sync_clock": { "default": "mdi:clock-check-outline" + }, + "reset_cutting_blade_usage_time": { + "default": "mdi:saw-blade" + } + }, + "event": { + "message": { + "default": "mdi:alert-circle-check-outline" } }, "number": { @@ -27,6 +32,9 @@ "error": { "default": "mdi:alert-circle-outline" }, + "inactive_reason": { + "default": "mdi:sleep" + }, "my_lawn_last_time_completed": { "default": "mdi:clock-outline" }, @@ -48,6 +56,26 @@ "work_area_progress": { "default": "mdi:collage" } + }, + "switch": { + "my_lawn_work_area": { + "default": "mdi:square-outline", + "state": { + "on": "mdi:square" + } + }, + "work_area_work_area": { + "default": "mdi:square-outline", + "state": { + "on": "mdi:square" + } + }, + "stay_out_zones": { + "default": "mdi:rhombus-outline", + "state": { + "on": "mdi:rhombus" + } + } } }, "services": { diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index 5a728265651..df312ae4ffd 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -18,9 +18,9 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry -from .const import DOMAIN +from .const import DOMAIN, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerAvailableEntity, handle_sending_exception +from .entity import AutomowerBaseEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) @@ -89,7 +89,7 @@ async def async_setup_entry( ) -class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): +class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity): """Defining each mower Entity.""" _attr_name = None @@ -108,18 +108,28 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): def activity(self) -> LawnMowerActivity: """Return the state of the mower.""" mower_attributes = self.mower_attributes + if mower_attributes.mower.state in ERROR_STATES: + return LawnMowerActivity.ERROR if mower_attributes.mower.state in PAUSED_STATES: return LawnMowerActivity.PAUSED - if (mower_attributes.mower.state == "RESTRICTED") or ( - mower_attributes.mower.activity in DOCKED_ACTIVITIES + if mower_attributes.mower.activity == MowerActivities.GOING_HOME: + return LawnMowerActivity.RETURNING + if ( + mower_attributes.mower.state is MowerStates.RESTRICTED + or mower_attributes.mower.activity in DOCKED_ACTIVITIES ): return LawnMowerActivity.DOCKED if mower_attributes.mower.state in MowerStates.IN_OPERATION: - if mower_attributes.mower.activity == MowerActivities.GOING_HOME: - return LawnMowerActivity.RETURNING return LawnMowerActivity.MOWING return LawnMowerActivity.ERROR + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return ( + super().available and self.mower_attributes.mower.state != MowerStates.OFF + ) + @property def work_areas(self) -> dict[int, WorkArea] | None: """Return the work areas of the mower.""" diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 29a4fafb8c0..49eb364858f 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.6.0"] + "requirements": ["aioautomower==2.1.2"] } diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 5ad8ad91b48..50be89e9d42 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -8,9 +8,10 @@ from operator import attrgetter from typing import TYPE_CHECKING, Any from aioautomower.model import ( + ExternalReasons, + InactiveReasons, MowerAttributes, MowerModes, - MowerStates, RestrictedReasons, WorkArea, ) @@ -27,6 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import AutomowerConfigEntry +from .const import ERROR_KEYS, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerBaseEntity, @@ -40,146 +42,17 @@ PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" -ERROR_KEYS = [ - "alarm_mower_in_motion", - "alarm_mower_lifted", - "alarm_mower_stopped", - "alarm_mower_switched_off", - "alarm_mower_tilted", - "alarm_outside_geofence", - "angular_sensor_problem", - "battery_problem", - "battery_restriction_due_to_ambient_temperature", - "can_error", - "charging_current_too_high", - "charging_station_blocked", - "charging_system_problem", - "collision_sensor_defect", - "collision_sensor_error", - "collision_sensor_problem_front", - "collision_sensor_problem_rear", - "com_board_not_available", - "communication_circuit_board_sw_must_be_updated", - "complex_working_area", - "connection_changed", - "connection_not_changed", - "connectivity_problem", - "connectivity_settings_restored", - "cutting_drive_motor_1_defect", - "cutting_drive_motor_2_defect", - "cutting_drive_motor_3_defect", - "cutting_height_blocked", - "cutting_height_problem_curr", - "cutting_height_problem_dir", - "cutting_height_problem_drive", - "cutting_height_problem", - "cutting_motor_problem", - "cutting_stopped_slope_too_steep", - "cutting_system_blocked", - "cutting_system_imbalance_warning", - "cutting_system_major_imbalance", - "destination_not_reachable", - "difficult_finding_home", - "docking_sensor_defect", - "electronic_problem", - "empty_battery", - "folding_cutting_deck_sensor_defect", - "folding_sensor_activated", - "geofence_problem", - "gps_navigation_problem", - "guide_1_not_found", - "guide_2_not_found", - "guide_3_not_found", - "guide_calibration_accomplished", - "guide_calibration_failed", - "high_charging_power_loss", - "high_internal_power_loss", - "high_internal_temperature", - "internal_voltage_error", - "invalid_battery_combination_invalid_combination_of_different_battery_types", - "invalid_sub_device_combination", - "invalid_system_configuration", - "left_brush_motor_overloaded", - "lift_sensor_defect", - "lifted", - "limited_cutting_height_range", - "loop_sensor_defect", - "loop_sensor_problem_front", - "loop_sensor_problem_left", - "loop_sensor_problem_rear", - "loop_sensor_problem_right", - "low_battery", - "memory_circuit_problem", - "mower_lifted", - "mower_tilted", - "no_accurate_position_from_satellites", - "no_confirmed_position", - "no_drive", - "no_error", - "no_loop_signal", - "no_power_in_charging_station", - "no_response_from_charger", - "outside_working_area", - "poor_signal_quality", - "reference_station_communication_problem", - "right_brush_motor_overloaded", - "safety_function_faulty", - "settings_restored", - "sim_card_locked", - "sim_card_not_found", - "sim_card_requires_pin", - "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", - "slope_too_steep", - "sms_could_not_be_sent", - "stop_button_problem", - "stuck_in_charging_station", - "switch_cord_problem", - "temporary_battery_problem", - "tilt_sensor_problem", - "too_high_discharge_current", - "too_high_internal_current", - "trapped", - "ultrasonic_problem", - "ultrasonic_sensor_1_defect", - "ultrasonic_sensor_2_defect", - "ultrasonic_sensor_3_defect", - "ultrasonic_sensor_4_defect", - "unexpected_cutting_height_adj", - "unexpected_error", - "upside_down", - "weak_gps_signal", - "wheel_drive_problem_left", - "wheel_drive_problem_rear_left", - "wheel_drive_problem_rear_right", - "wheel_drive_problem_right", - "wheel_motor_blocked_left", - "wheel_motor_blocked_rear_left", - "wheel_motor_blocked_rear_right", - "wheel_motor_blocked_right", - "wheel_motor_overloaded_left", - "wheel_motor_overloaded_rear_left", - "wheel_motor_overloaded_rear_right", - "wheel_motor_overloaded_right", - "work_area_not_valid", - "wrong_loop_signal", - "wrong_pin_code", - "zone_generator_problem", -] - -ERROR_STATES = [ - MowerStates.ERROR_AT_POWER_UP, - MowerStates.ERROR, - MowerStates.FATAL_ERROR, - MowerStates.OFF, - MowerStates.STOPPED, - MowerStates.WAIT_POWER_UP, - MowerStates.WAIT_UPDATING, -] - -ERROR_KEY_LIST = list( - dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) +ERROR_KEY_LIST = sorted( + set(ERROR_KEYS) | {state.lower() for state in ERROR_STATES} | {"no_error"} ) +INACTIVE_REASONS: list = [ + InactiveReasons.NONE, + InactiveReasons.PLANNING, + InactiveReasons.SEARCHING_FOR_SATELLITES, +] + + RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, RestrictedReasons.DAILY_LIMIT, @@ -191,11 +64,37 @@ RESTRICTED_REASONS: list = [ RestrictedReasons.PARK_OVERRIDE, RestrictedReasons.SENSOR, RestrictedReasons.WEEK_SCHEDULE, + ExternalReasons.AMAZON_ALEXA, + ExternalReasons.DEVELOPER_PORTAL, + ExternalReasons.GARDENA_SMART_SYSTEM, + ExternalReasons.GOOGLE_ASSISTANT, + ExternalReasons.HOME_ASSISTANT, + ExternalReasons.IFTTT, + ExternalReasons.IFTTT_APPLETS, + ExternalReasons.IFTTT_CALENDAR_CONNECTION, + ExternalReasons.SMART_ROUTINE, + ExternalReasons.SMART_ROUTINE_FROST_GUARD, + ExternalReasons.SMART_ROUTINE_RAIN_GUARD, + ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION, ] STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active" +@callback +def _get_restricted_reason(data: MowerAttributes) -> str: + """Return the restricted reason. + + If there is an external reason, return that instead, if it's available. + """ + if ( + data.planner.restricted_reason == RestrictedReasons.EXTERNAL + and data.planner.external_reason is not None + ): + return data.planner.external_reason + return data.planner.restricted_reason + + @callback def _get_work_area_names(data: MowerAttributes) -> list[str]: """Return a list with all work area names.""" @@ -401,7 +300,15 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( translation_key="restricted_reason", device_class=SensorDeviceClass.ENUM, option_fn=lambda data: RESTRICTED_REASONS, - value_fn=attrgetter("planner.restricted_reason"), + value_fn=_get_restricted_reason, + ), + AutomowerSensorEntityDescription( + key="inactive_reason", + translation_key="inactive_reason", + exists_fn=lambda data: data.capabilities.work_areas, + device_class=SensorDeviceClass.ENUM, + option_fn=lambda data: INACTIVE_REASONS, + value_fn=attrgetter("mower.inactive_reason"), ), AutomowerSensorEntityDescription( key="work_area", @@ -534,6 +441,11 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): """Return the state attributes.""" return self.entity_description.extra_state_attributes_fn(self.mower_attributes) + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return super().available and self.native_value is not None + class WorkAreaSensorEntity(WorkAreaAvailableEntity, SensorEntity): """Defining the Work area sensors with WorkAreaSensorEntityDescription.""" diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 9e808c66878..c10e56ec7c8 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -53,6 +53,161 @@ }, "sync_clock": { "name": "Sync clock" + }, + "reset_cutting_blade_usage_time": { + "name": "Reset cutting blade usage time" + } + }, + "event": { + "message": { + "name": "Message", + "state_attributes": { + "event_type": { + "state": { + "alarm_mower_in_motion": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_in_motion%]", + "alarm_mower_lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_lifted%]", + "alarm_mower_stopped": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_stopped%]", + "alarm_mower_switched_off": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_switched_off%]", + "alarm_mower_tilted": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_tilted%]", + "alarm_outside_geofence": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_outside_geofence%]", + "angular_sensor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::angular_sensor_problem%]", + "battery_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::battery_problem%]", + "battery_restriction_due_to_ambient_temperature": "[%key:component::husqvarna_automower::entity::sensor::error::state::battery_restriction_due_to_ambient_temperature%]", + "can_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::can_error%]", + "charging_current_too_high": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_current_too_high%]", + "charging_station_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_station_blocked%]", + "charging_system_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_system_problem%]", + "collision_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_defect%]", + "collision_sensor_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_error%]", + "collision_sensor_problem_front": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_problem_front%]", + "collision_sensor_problem_rear": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_problem_rear%]", + "com_board_not_available": "[%key:component::husqvarna_automower::entity::sensor::error::state::com_board_not_available%]", + "communication_circuit_board_sw_must_be_updated": "[%key:component::husqvarna_automower::entity::sensor::error::state::communication_circuit_board_sw_must_be_updated%]", + "complex_working_area": "[%key:component::husqvarna_automower::entity::sensor::error::state::complex_working_area%]", + "connection_changed": "[%key:component::husqvarna_automower::entity::sensor::error::state::connection_changed%]", + "connection_not_changed": "[%key:component::husqvarna_automower::entity::sensor::error::state::connection_not_changed%]", + "connectivity_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::connectivity_problem%]", + "connectivity_settings_restored": "[%key:component::husqvarna_automower::entity::sensor::error::state::connectivity_settings_restored%]", + "cutting_drive_motor_1_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_1_defect%]", + "cutting_drive_motor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_2_defect%]", + "cutting_drive_motor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_3_defect%]", + "cutting_height_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_blocked%]", + "cutting_height_problem_curr": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_curr%]", + "cutting_height_problem_dir": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_dir%]", + "cutting_height_problem_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_drive%]", + "cutting_height_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem%]", + "cutting_motor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_motor_problem%]", + "cutting_stopped_slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_stopped_slope_too_steep%]", + "cutting_system_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_blocked%]", + "cutting_system_imbalance_warning": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_imbalance_warning%]", + "cutting_system_major_imbalance": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_major_imbalance%]", + "destination_not_reachable": "[%key:component::husqvarna_automower::entity::sensor::error::state::destination_not_reachable%]", + "difficult_finding_home": "[%key:component::husqvarna_automower::entity::sensor::error::state::difficult_finding_home%]", + "docking_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::docking_sensor_defect%]", + "electronic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::electronic_problem%]", + "empty_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::empty_battery%]", + "error_at_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::error_at_power_up%]", + "error": "[%key:common::state::error%]", + "fatal_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::fatal_error%]", + "folding_cutting_deck_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_cutting_deck_sensor_defect%]", + "folding_sensor_activated": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_sensor_activated%]", + "geofence_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::geofence_problem%]", + "gps_navigation_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::gps_navigation_problem%]", + "guide_1_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_1_not_found%]", + "guide_2_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_2_not_found%]", + "guide_3_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_3_not_found%]", + "guide_calibration_accomplished": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_calibration_accomplished%]", + "guide_calibration_failed": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_calibration_failed%]", + "high_charging_power_loss": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_charging_power_loss%]", + "high_internal_power_loss": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_internal_power_loss%]", + "high_internal_temperature": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_internal_temperature%]", + "internal_voltage_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::internal_voltage_error%]", + "invalid_battery_combination_invalid_combination_of_different_battery_types": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_battery_combination_invalid_combination_of_different_battery_types%]", + "invalid_sub_device_combination": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_sub_device_combination%]", + "invalid_system_configuration": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_system_configuration%]", + "left_brush_motor_overloaded": "[%key:component::husqvarna_automower::entity::sensor::error::state::left_brush_motor_overloaded%]", + "lift_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::lift_sensor_defect%]", + "lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::lifted%]", + "limited_cutting_height_range": "[%key:component::husqvarna_automower::entity::sensor::error::state::limited_cutting_height_range%]", + "loop_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_defect%]", + "loop_sensor_problem_front": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_front%]", + "loop_sensor_problem_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_left%]", + "loop_sensor_problem_rear": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_rear%]", + "loop_sensor_problem_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_right%]", + "low_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::low_battery%]", + "memory_circuit_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::memory_circuit_problem%]", + "mower_lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::mower_lifted%]", + "mower_tilted": "[%key:component::husqvarna_automower::entity::sensor::error::state::mower_tilted%]", + "no_accurate_position_from_satellites": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_accurate_position_from_satellites%]", + "no_confirmed_position": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_confirmed_position%]", + "no_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_drive%]", + "no_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_error%]", + "no_loop_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_loop_signal%]", + "no_power_in_charging_station": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_power_in_charging_station%]", + "no_response_from_charger": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_response_from_charger%]", + "off": "[%key:common::state::off%]", + "outside_working_area": "[%key:component::husqvarna_automower::entity::sensor::error::state::outside_working_area%]", + "poor_signal_quality": "[%key:component::husqvarna_automower::entity::sensor::error::state::poor_signal_quality%]", + "reference_station_communication_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::reference_station_communication_problem%]", + "right_brush_motor_overloaded": "[%key:component::husqvarna_automower::entity::sensor::error::state::right_brush_motor_overloaded%]", + "safety_function_faulty": "[%key:component::husqvarna_automower::entity::sensor::error::state::safety_function_faulty%]", + "settings_restored": "[%key:component::husqvarna_automower::entity::sensor::error::state::settings_restored%]", + "sim_card_locked": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_locked%]", + "sim_card_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_not_found%]", + "sim_card_requires_pin": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_requires_pin%]", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern": "[%key:component::husqvarna_automower::entity::sensor::error::state::slipped_mower_has_slipped_situation_not_solved_with_moving_pattern%]", + "slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::slope_too_steep%]", + "sms_could_not_be_sent": "[%key:component::husqvarna_automower::entity::sensor::error::state::sms_could_not_be_sent%]", + "stop_button_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::stop_button_problem%]", + "stopped": "[%key:common::state::stopped%]", + "stuck_in_charging_station": "[%key:component::husqvarna_automower::entity::sensor::error::state::stuck_in_charging_station%]", + "switch_cord_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::switch_cord_problem%]", + "temporary_battery_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::temporary_battery_problem%]", + "tilt_sensor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::tilt_sensor_problem%]", + "too_high_discharge_current": "[%key:component::husqvarna_automower::entity::sensor::error::state::too_high_discharge_current%]", + "too_high_internal_current": "[%key:component::husqvarna_automower::entity::sensor::error::state::too_high_internal_current%]", + "trapped": "[%key:component::husqvarna_automower::entity::sensor::error::state::trapped%]", + "ultrasonic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_problem%]", + "ultrasonic_sensor_1_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_1_defect%]", + "ultrasonic_sensor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_2_defect%]", + "ultrasonic_sensor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_3_defect%]", + "ultrasonic_sensor_4_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_4_defect%]", + "unexpected_cutting_height_adj": "[%key:component::husqvarna_automower::entity::sensor::error::state::unexpected_cutting_height_adj%]", + "unexpected_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::unexpected_error%]", + "upside_down": "[%key:component::husqvarna_automower::entity::sensor::error::state::upside_down%]", + "wait_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::wait_power_up%]", + "wait_updating": "[%key:component::husqvarna_automower::entity::sensor::error::state::wait_updating%]", + "weak_gps_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::weak_gps_signal%]", + "wheel_drive_problem_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_left%]", + "wheel_drive_problem_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_rear_left%]", + "wheel_drive_problem_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_rear_right%]", + "wheel_drive_problem_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_right%]", + "wheel_motor_blocked_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_left%]", + "wheel_motor_blocked_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_rear_left%]", + "wheel_motor_blocked_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_rear_right%]", + "wheel_motor_blocked_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_right%]", + "wheel_motor_overloaded_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_left%]", + "wheel_motor_overloaded_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_rear_left%]", + "wheel_motor_overloaded_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_rear_right%]", + "wheel_motor_overloaded_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_right%]", + "work_area_not_valid": "[%key:component::husqvarna_automower::entity::sensor::error::state::work_area_not_valid%]", + "wrong_loop_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::wrong_loop_signal%]", + "wrong_pin_code": "[%key:component::husqvarna_automower::entity::sensor::error::state::wrong_pin_code%]", + "zone_generator_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::zone_generator_problem%]" + } + }, + "severity": { + "state": { + "fatal": "Fatal", + "error": "[%key:common::state::error%]", + "warning": "Warning", + "info": "Info", + "debug": "Debug", + "sw": "Software", + "unknown": "Unknown" + } + } + } } }, "number": { @@ -213,6 +368,14 @@ "zone_generator_problem": "Zone generator problem" } }, + "inactive_reason": { + "name": "Inactive reason", + "state": { + "none": "No inactivity", + "planning": "Planning", + "searching_for_satellites": "Searching for satellites" + } + }, "my_lawn_last_time_completed": { "name": "My lawn last time completed" }, @@ -234,16 +397,28 @@ "restricted_reason": { "name": "Restricted reason", "state": { - "none": "No restrictions", - "week_schedule": "Week schedule", - "park_override": "Park override", - "sensor": "Weather timer", + "all_work_areas_completed": "All work areas completed", + "amazon_alexa": "Amazon Alexa", "daily_limit": "Daily limit", + "developer_portal": "Developer Portal", + "external": "External", "fota": "Firmware Over-the-Air update running", "frost": "Frost", - "all_work_areas_completed": "All work areas completed", - "external": "External", - "not_applicable": "Not applicable" + "gardena_smart_system": "Gardena Smart System", + "google_assistant": "Google Assistant", + "home_assistant": "Home Assistant", + "ifttt_applets": "IFTTT applets", + "ifttt_calendar_connection": "IFTTT calendar connection", + "ifttt": "IFTTT", + "none": "No restrictions", + "not_applicable": "Not applicable", + "park_override": "Park override", + "sensor": "Weather timer", + "smart_routine_frost_guard": "Frost guard", + "smart_routine_rain_guard": "Rain guard", + "smart_routine_wildlife_protection": "Wildlife protection", + "smart_routine": "Generic smart routine", + "week_schedule": "Week schedule" } }, "total_charging_time": { diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index ca07d1ab8d2..fd4521549a2 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -15,12 +15,15 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import LOGGER from .coordinator import HusqvarnaCoordinator +type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator] + PLATFORMS = [ Platform.LAWN_MOWER, + Platform.SENSOR, ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool: """Set up Husqvarna Autoconnect Bluetooth from a config entry.""" address = entry.data[CONF_ADDRESS] channel_id = entry.data[CONF_CLIENT_ID] @@ -54,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator: HusqvarnaCoordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower_ble/coordinator.py b/homeassistant/components/husqvarna_automower_ble/coordinator.py index dde3462c081..ef9ccfa5a47 100644 --- a/homeassistant/components/husqvarna_automower_ble/coordinator.py +++ b/homeassistant/components/husqvarna_automower_ble/coordinator.py @@ -3,30 +3,31 @@ from __future__ import annotations from datetime import timedelta +from typing import TYPE_CHECKING from automower_ble.mower import Mower from bleak import BleakError from bleak_retry_connector import close_stale_connections_by_address from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER +if TYPE_CHECKING: + from . import HusqvarnaConfigEntry + SCAN_INTERVAL = timedelta(seconds=60) -class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): +class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, str | int]]): """Class to manage fetching data.""" - config_entry: ConfigEntry - def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HusqvarnaConfigEntry, mower: Mower, address: str, channel_id: str, @@ -66,11 +67,11 @@ class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): except BleakError as err: raise UpdateFailed("Failed to connect") from err - async def _async_update_data(self) -> dict[str, bytes]: + async def _async_update_data(self) -> dict[str, str | int]: """Poll the device.""" LOGGER.debug("Polling device") - data: dict[str, bytes] = {} + data: dict[str, str | int] = {} try: if not self.mower.is_connected(): diff --git a/homeassistant/components/husqvarna_automower_ble/entity.py b/homeassistant/components/husqvarna_automower_ble/entity.py index d2873d933ff..cb62f36027a 100644 --- a/homeassistant/components/husqvarna_automower_ble/entity.py +++ b/homeassistant/components/husqvarna_automower_ble/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -28,3 +29,18 @@ class HusqvarnaAutomowerBleEntity(CoordinatorEntity[HusqvarnaCoordinator]): def available(self) -> bool: """Return if entity is available.""" return super().available and self.coordinator.mower.is_connected() + + +class HusqvarnaAutomowerBleDescriptorEntity(HusqvarnaAutomowerBleEntity): + """Coordinator entity for entities with entity description.""" + + def __init__( + self, coordinator: HusqvarnaCoordinator, description: EntityDescription + ) -> None: + """Initialize description entity.""" + super().__init__(coordinator) + + self._attr_unique_id = ( + f"{coordinator.address}_{coordinator.channel_id}_{description.key}" + ) + self.entity_description = description diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py index 4b239394c2d..4b4a16ba1db 100644 --- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py @@ -10,10 +10,10 @@ from homeassistant.components.lawn_mower import ( LawnMowerEntity, LawnMowerEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import HusqvarnaConfigEntry from .const import LOGGER from .coordinator import HusqvarnaCoordinator from .entity import HusqvarnaAutomowerBleEntity @@ -21,11 +21,11 @@ from .entity import HusqvarnaAutomowerBleEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HusqvarnaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AutomowerLawnMower integration from a config entry.""" - coordinator: HusqvarnaCoordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data address = coordinator.address async_add_entities( diff --git a/homeassistant/components/husqvarna_automower_ble/sensor.py b/homeassistant/components/husqvarna_automower_ble/sensor.py new file mode 100644 index 00000000000..f747133c950 --- /dev/null +++ b/homeassistant/components/husqvarna_automower_ble/sensor.py @@ -0,0 +1,51 @@ +"""Support for sensor entities.""" + +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HusqvarnaConfigEntry +from .entity import HusqvarnaAutomowerBleDescriptorEntity + +DESCRIPTIONS = ( + SensorEntityDescription( + key="battery_level", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HusqvarnaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Husqvarna Automower Ble sensor based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HusqvarnaAutomowerBleSensor(coordinator, description) + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class HusqvarnaAutomowerBleSensor(HusqvarnaAutomowerBleDescriptorEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: SensorEntityDescription + + @property + def native_value(self) -> str | int: + """Return the previously fetched value.""" + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index 75faf1923df..d2dd7ff4fa3 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -2,46 +2,28 @@ from __future__ import annotations -import logging - -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) +from .const import PLATFORMS +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: HuumConfigEntry) -> bool: """Set up Huum from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] + coordinator = HuumDataUpdateCoordinator( + hass=hass, + config_entry=config_entry, + ) - huum = Huum(username, password, session=async_get_clientsession(hass)) + await coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = coordinator - try: - await huum.status() - except (Forbidden, NotAuthenticated) as err: - _LOGGER.error("Could not log in to Huum with given credentials") - raise ConfigEntryNotReady( - "Could not log in to Huum with given credentials" - ) from err - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = huum - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HuumConfigEntry +) -> 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(config_entry, PLATFORMS) diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py new file mode 100644 index 00000000000..7bc03e9fe94 --- /dev/null +++ b/homeassistant/components/huum/binary_sensor.py @@ -0,0 +1,41 @@ +"""Sensor for door state.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up door sensor.""" + async_add_entities( + [HuumDoorSensor(config_entry.runtime_data)], + ) + + +class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor.""" + + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the BinarySensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_door" + + @property + def is_on(self) -> bool | None: + """Return the current value.""" + return not self.coordinator.data.door_closed diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 84173260d04..af4e8cc3623 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -7,38 +7,33 @@ from typing import Any from huum.const import SaunaStatus from huum.exceptions import SafetyException -from huum.huum import Huum -from huum.schemas import HuumStatusResponse from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HuumConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Huum sauna with config flow.""" - huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id] - - async_add_entities([HuumDevice(huum_handler, entry.entry_id)], True) + async_add_entities([HuumDevice(entry.runtime_data)]) -class HuumDevice(ClimateEntity): +class HuumDevice(HuumBaseEntity, ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -49,29 +44,28 @@ class HuumDevice(ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_max_temp = 110 - _attr_min_temp = 40 - _attr_has_entity_name = True _attr_name = None - _target_temperature: int | None = None - _status: HuumStatusResponse | None = None - - def __init__(self, huum_handler: Huum, unique_id: str) -> None: + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: """Initialize the heater.""" - self._attr_unique_id = unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name="Huum sauna", - manufacturer="Huum", - ) + super().__init__(coordinator) - self._huum_handler = huum_handler + self._attr_unique_id = coordinator.config_entry.entry_id + + @property + def min_temp(self) -> int: + """Return configured minimal temperature.""" + return self.coordinator.data.sauna_config.min_temp + + @property + def max_temp(self) -> int: + """Return configured maximum temperature.""" + return self.coordinator.data.sauna_config.max_temp @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - if self._status and self._status.status == SaunaStatus.ONLINE_HEATING: + if self.coordinator.data.status == SaunaStatus.ONLINE_HEATING: return HVACMode.HEAT return HVACMode.OFF @@ -85,49 +79,37 @@ class HuumDevice(ClimateEntity): @property def current_temperature(self) -> int | None: """Return the current temperature.""" - if (status := self._status) is not None: - return status.temperature - return None + return self.coordinator.data.temperature @property def target_temperature(self) -> int: """Return the temperature we try to reach.""" - return self._target_temperature or int(self.min_temp) + return self.coordinator.data.target_temperature or int(self.min_temp) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" if hvac_mode == HVACMode.HEAT: - await self._turn_on(self.target_temperature) + # Make sure to send integers + # The temperature is not always an integer if the user uses Fahrenheit + temperature = int(self.target_temperature) + await self._turn_on(temperature) elif hvac_mode == HVACMode.OFF: - await self._huum_handler.turn_off() + await self.coordinator.huum.turn_off() + await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if temperature is None or self.hvac_mode != HVACMode.HEAT: return - self._target_temperature = temperature + temperature = int(temperature) - if self.hvac_mode == HVACMode.HEAT: - await self._turn_on(temperature) - - async def async_update(self) -> None: - """Get the latest status data. - - We get the latest status first from the status endpoints of the sauna. - If that data does not include the temperature, that means that the sauna - is off, we then call the off command which will in turn return the temperature. - This is a workaround for getting the temperature as the Huum API does not - return the target temperature of a sauna that is off, even if it can have - a target temperature at that time. - """ - self._status = await self._huum_handler.status_from_status_or_stop() - if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: - self._target_temperature = self._status.target_temperature + await self._turn_on(temperature) + await self.coordinator.async_refresh() async def _turn_on(self, temperature: int) -> None: try: - await self._huum_handler.turn_on(temperature) + await self.coordinator.huum.turn_on(temperature) except (ValueError, SafetyException) as err: _LOGGER.error(str(err)) raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 6a5fd96b99d..b6f7f883120 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -37,12 +37,12 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - huum_handler = Huum( + huum = Huum( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=async_get_clientsession(self.hass), ) - await huum_handler.status() + 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") diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 69dea45b218..177c035f041 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -4,4 +4,8 @@ from homeassistant.const import Platform DOMAIN = "huum" -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER] + +CONFIG_STEAMER = 1 +CONFIG_LIGHT = 2 +CONFIG_STEAMER_AND_LIGHT = 3 diff --git a/homeassistant/components/huum/coordinator.py b/homeassistant/components/huum/coordinator.py new file mode 100644 index 00000000000..6580ca99da7 --- /dev/null +++ b/homeassistant/components/huum/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for Huum.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +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 + +type HuumConfigEntry = ConfigEntry[HuumDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(seconds=30) + + +class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]): + """Class to manage fetching data from the API.""" + + config_entry: HuumConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HuumConfigEntry, + ) -> None: + """Initialize.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + + self.huum = Huum( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> HuumStatusResponse: + """Get the latest status data.""" + + try: + return await self.huum.status() + except (Forbidden, NotAuthenticated) as err: + _LOGGER.error("Could not log in to Huum with given credentials") + raise UpdateFailed( + "Could not log in to Huum with given credentials" + ) from err diff --git a/homeassistant/components/huum/entity.py b/homeassistant/components/huum/entity.py new file mode 100644 index 00000000000..cd30119f6fe --- /dev/null +++ b/homeassistant/components/huum/entity.py @@ -0,0 +1,24 @@ +"""Define Huum Base entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HuumDataUpdateCoordinator + + +class HuumBaseEntity(CoordinatorEntity[HuumDataUpdateCoordinator]): + """Huum base Entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="Huum sauna", + manufacturer="Huum", + model="UKU WiFi", + ) diff --git a/homeassistant/components/huum/icons.json b/homeassistant/components/huum/icons.json new file mode 100644 index 00000000000..4281cdbde2a --- /dev/null +++ b/homeassistant/components/huum/icons.json @@ -0,0 +1,13 @@ +{ + "entity": { + "number": { + "humidity": { + "default": "mdi:water", + "range": { + "0": "mdi:water-off", + "1": "mdi:water" + } + } + } + } +} diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py new file mode 100644 index 00000000000..9d3ec54101d --- /dev/null +++ b/homeassistant/components/huum/light.py @@ -0,0 +1,62 @@ +"""Control for light.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up light if applicable.""" + coordinator = config_entry.runtime_data + + # Light is configured for this sauna. + if coordinator.data.config in [CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT]: + async_add_entities([HuumLight(coordinator)]) + + +class HuumLight(HuumBaseEntity, LightEntity): + """Representation of a light.""" + + _attr_translation_key = "light" + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_color_mode = ColorMode.ONOFF + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the light.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.config_entry.entry_id + + @property + def is_on(self) -> bool | None: + """Return the current light status.""" + return self.coordinator.data.light == 1 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + if not self.is_on: + await self._toggle_light() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn device off.""" + if self.is_on: + await self._toggle_light() + + async def _toggle_light(self) -> None: + await self.coordinator.huum.toggle_light() + await self.coordinator.async_refresh() diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 38562e1a072..79bfd9795cb 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -1,9 +1,9 @@ { "domain": "huum", "name": "Huum", - "codeowners": ["@frwickst"], + "codeowners": ["@frwickst", "@vincentwolsink"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.12"] + "requirements": ["huum==0.8.1"] } diff --git a/homeassistant/components/huum/number.py b/homeassistant/components/huum/number.py new file mode 100644 index 00000000000..daaf348c029 --- /dev/null +++ b/homeassistant/components/huum/number.py @@ -0,0 +1,64 @@ +"""Control for steamer.""" + +from __future__ import annotations + +import logging + +from huum.const import SaunaStatus + +from homeassistant.components.number import NumberEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONFIG_STEAMER, CONFIG_STEAMER_AND_LIGHT +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up steamer if applicable.""" + coordinator = config_entry.runtime_data + + # Light is configured for this sauna. + if coordinator.data.config in [CONFIG_STEAMER, CONFIG_STEAMER_AND_LIGHT]: + async_add_entities([HuumSteamer(coordinator)]) + + +class HuumSteamer(HuumBaseEntity, NumberEntity): + """Representation of a steamer.""" + + _attr_translation_key = "humidity" + _attr_native_max_value = 10 + _attr_native_min_value = 0 + _attr_native_step = 1 + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the steamer.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.config_entry.entry_id + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.coordinator.data.humidity + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + target_temperature = self.coordinator.data.target_temperature + if ( + not target_temperature + or self.coordinator.data.status != SaunaStatus.ONLINE_HEATING + ): + return + + await self.coordinator.huum.turn_on( + temperature=target_temperature, humidity=int(value) + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 68ab1adde6f..13c2e5c85f6 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -18,5 +18,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + }, + "number": { + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } + } } } diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 45537a2cc73..f2177d2144a 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime -from pydrawise import Zone +from pydrawise import Controller, Zone import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -81,31 +81,46 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise binary_sensor platform.""" coordinators = config_entry.runtime_data - entities: list[HydrawiseBinarySensor] = [] - for controller in coordinators.main.data.controllers.values(): - entities.extend( - HydrawiseBinarySensor(coordinators.main, description, controller) - for description in CONTROLLER_BINARY_SENSORS - ) - entities.extend( - HydrawiseBinarySensor( - coordinators.main, - description, - controller, - sensor_id=sensor.id, + + def _add_new_controllers(controllers: Iterable[Controller]) -> None: + entities: list[HydrawiseBinarySensor] = [] + for controller in controllers: + entities.extend( + HydrawiseBinarySensor(coordinators.main, description, controller) + for description in CONTROLLER_BINARY_SENSORS ) - for sensor in controller.sensors - for description in RAIN_SENSOR_BINARY_SENSOR - if "rain sensor" in sensor.model.name.lower() - ) - entities.extend( + entities.extend( + HydrawiseBinarySensor( + coordinators.main, + description, + controller, + sensor_id=sensor.id, + ) + for sensor in controller.sensors + for description in RAIN_SENSOR_BINARY_SENSOR + if "rain sensor" in sensor.model.name.lower() + ) + async_add_entities(entities) + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( HydrawiseZoneBinarySensor( coordinators.main, description, controller, zone_id=zone.id ) - for zone in controller.zones + for zone, controller in zones for description in ZONE_BINARY_SENSORS ) - async_add_entities(entities) + + _add_new_controllers(coordinators.main.data.controllers.values()) + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] + ) + coordinators.main.new_controllers_callbacks.append(_add_new_controllers) + coordinators.main.new_zones_callbacks.append(_add_new_zones) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_RESUME, None, "resume") platform.async_register_entity_service( diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index beaf450a586..502fd14cfbd 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -13,6 +13,7 @@ DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = timedelta(minutes=15) MANUFACTURER = "Hydrawise" +MODEL_ZONE = "Zone" MAIN_SCAN_INTERVAL = timedelta(minutes=5) WATER_USE_SCAN_INTERVAL = timedelta(minutes=60) diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 15d286801f9..308ffc23e36 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,17 +2,26 @@ from __future__ import annotations +from collections.abc import Callable, Iterable from dataclasses import dataclass, field from pydrawise import HydrawiseBase from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import now -from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL +from .const import ( + DOMAIN, + LOGGER, + MAIN_SCAN_INTERVAL, + MODEL_ZONE, + WATER_USE_SCAN_INTERVAL, +) type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators] @@ -24,6 +33,7 @@ class HydrawiseData: user: User controllers: dict[int, Controller] = field(default_factory=dict) zones: dict[int, Zone] = field(default_factory=dict) + zone_id_to_controller: dict[int, Controller] = field(default_factory=dict) sensors: dict[int, Sensor] = field(default_factory=dict) daily_water_summary: dict[int, ControllerWaterUseSummary] = field( default_factory=dict @@ -68,6 +78,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): update_interval=MAIN_SCAN_INTERVAL, ) self.api = api + self.new_controllers_callbacks: list[ + Callable[[Iterable[Controller]], None] + ] = [] + self.new_zones_callbacks: list[ + Callable[[Iterable[tuple[Zone, Controller]]], None] + ] = [] + self.async_add_listener(self._add_remove_zones) async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" @@ -80,10 +97,81 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): controller.zones = await self.api.get_zones(controller) for zone in controller.zones: data.zones[zone.id] = zone + data.zone_id_to_controller[zone.id] = controller for sensor in controller.sensors: data.sensors[sensor.id] = sensor return data + @callback + def _add_remove_zones(self) -> None: + """Add newly discovered zones and remove nonexistent ones.""" + if self.data is None: + # Likely a setup error; ignore. + # Despite what mypy thinks, this is still reachable. Without this check, + # the test_connect_retry test in test_init.py fails. + return # type: ignore[unreachable] + + device_registry = dr.async_get(self.hass) + devices = dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id + ) + previous_zones: set[str] = set() + previous_zones_by_id: dict[str, DeviceEntry] = {} + previous_controllers: set[str] = set() + previous_controllers_by_id: dict[str, DeviceEntry] = {} + for device in devices: + for domain, identifier in device.identifiers: + if domain == DOMAIN: + if device.model == MODEL_ZONE: + previous_zones.add(identifier) + previous_zones_by_id[identifier] = device + else: + previous_controllers.add(identifier) + previous_controllers_by_id[identifier] = device + continue + + current_zones = {str(zone_id) for zone_id in self.data.zones} + current_controllers = { + str(controller_id) for controller_id in self.data.controllers + } + + if removed_zones := previous_zones - current_zones: + LOGGER.debug("Removed zones: %s", ", ".join(removed_zones)) + for zone_id in removed_zones: + device_registry.async_update_device( + device_id=previous_zones_by_id[zone_id].id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if removed_controllers := previous_controllers - current_controllers: + LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers)) + for controller_id in removed_controllers: + device_registry.async_update_device( + device_id=previous_controllers_by_id[controller_id].id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if new_controller_ids := current_controllers - previous_controllers: + LOGGER.debug("New controllers found: %s", ", ".join(new_controller_ids)) + new_controllers = [ + self.data.controllers[controller_id] + for controller_id in map(int, new_controller_ids) + ] + for new_controller_callback in self.new_controllers_callbacks: + new_controller_callback(new_controllers) + + if new_zone_ids := current_zones - previous_zones: + LOGGER.debug("New zones found: %s", ", ".join(new_zone_ids)) + new_zones = [ + ( + self.data.zones[zone_id], + self.data.zone_id_to_controller[zone_id], + ) + for zone_id in map(int, new_zone_ids) + ] + for new_zone_callback in self.new_zones_callbacks: + new_zone_callback(new_zones) + class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): """Data Update Coordinator for Hydrawise Water Use. diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 67dd6375b0e..58153d43634 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER, MODEL_ZONE from .coordinator import HydrawiseDataUpdateCoordinator @@ -40,7 +40,9 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): identifiers={(DOMAIN, self._device_id)}, name=self.zone.name if zone_id is not None else controller.name, model=( - "Zone" if zone_id is not None else controller.hardware.model.description + MODEL_ZONE + if zone_id is not None + else controller.hardware.model.description ), manufacturer=MANUFACTURER, ) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 03b9dc68a79..a599ffa888e 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.6.0"] + "requirements": ["pydrawise==2025.7.0"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index ce0bc5a0997..3a04a587bb4 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise.schema import ControllerWaterUseSummary +from pydrawise.schema import Controller, ControllerWaterUseSummary, Zone from homeassistant.components.sensor import ( SensorDeviceClass, @@ -31,7 +31,9 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription): def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary: - return sensor.coordinator.data.daily_water_summary[sensor.controller.id] + return sensor.coordinator.data.daily_water_summary.get( + sensor.controller.id, ControllerWaterUseSummary() + ) WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( @@ -133,44 +135,65 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise sensor platform.""" coordinators = config_entry.runtime_data - entities: list[HydrawiseSensor] = [] - for controller in coordinators.main.data.controllers.values(): - entities.extend( - HydrawiseSensor(coordinators.water_use, description, controller) - for description in WATER_USE_CONTROLLER_SENSORS + + def _has_flow_sensor(controller: Controller) -> bool: + daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get( + controller.id, ControllerWaterUseSummary() ) - entities.extend( - HydrawiseSensor( - coordinators.water_use, description, controller, zone_id=zone.id - ) - for zone in controller.zones - for description in WATER_USE_ZONE_SENSORS - ) - entities.extend( - HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id) - for zone in controller.zones - for description in ZONE_SENSORS - ) - if ( - coordinators.water_use.data.daily_water_summary[controller.id].total_use - is not None - ): - # we have a flow sensor for this controller + return daily_water_use_summary.total_use is not None + + def _add_new_controllers(controllers: Iterable[Controller]) -> None: + entities: list[HydrawiseSensor] = [] + for controller in controllers: entities.extend( HydrawiseSensor(coordinators.water_use, description, controller) - for description in FLOW_CONTROLLER_SENSORS + for description in WATER_USE_CONTROLLER_SENSORS ) - entities.extend( + if _has_flow_sensor(controller): + entities.extend( + HydrawiseSensor(coordinators.water_use, description, controller) + for description in FLOW_CONTROLLER_SENSORS + ) + async_add_entities(entities) + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + [ + HydrawiseSensor( + coordinators.water_use, description, controller, zone_id=zone.id + ) + for zone, controller in zones + for description in WATER_USE_ZONE_SENSORS + ] + + [ + HydrawiseSensor( + coordinators.main, description, controller, zone_id=zone.id + ) + for zone, controller in zones + for description in ZONE_SENSORS + ] + + [ HydrawiseSensor( coordinators.water_use, description, controller, zone_id=zone.id, ) - for zone in controller.zones + for zone, controller in zones for description in FLOW_ZONE_SENSORS - ) - async_add_entities(entities) + if _has_flow_sensor(controller) + ] + ) + + _add_new_controllers(coordinators.main.data.controllers.values()) + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] + ) + coordinators.main.new_controllers_callbacks.append(_add_new_controllers) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseSensor(HydrawiseEntity, SensorEntity): diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 47543aa2f8f..29b6d741a5e 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Hydrawise Login", + "title": "Hydrawise login", "description": "Please provide the username and password for your Hydrawise cloud account:", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -10,7 +10,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "You can generate an API Key in the 'Account Details' section of the Hydrawise app" + "api_key": "You can generate an API key in the 'Account Details' section of the Hydrawise app" } }, "reauth_confirm": { diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 7a77f27265b..238e249e1f6 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise import HydrawiseBase, Zone +from pydrawise import Controller, HydrawiseBase, Zone from homeassistant.components.switch import ( SwitchDeviceClass, @@ -66,12 +66,21 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise switch platform.""" coordinators = config_entry.runtime_data - async_add_entities( - HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) - for controller in coordinators.main.data.controllers.values() - for zone in controller.zones - for description in SWITCH_TYPES + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) + for zone, controller in zones + for description in SWITCH_TYPES + ) + + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] ) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 85a91c807b2..56dd56e7d21 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Any -from pydrawise.schema import Zone +from pydrawise.schema import Controller, Zone from homeassistant.components.valve import ( ValveDeviceClass, @@ -33,12 +34,21 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise valve platform.""" coordinators = config_entry.runtime_data - async_add_entities( - HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) - for controller in coordinators.main.data.controllers.values() - for zone in controller.zones - for description in VALVE_TYPES + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) + for zone, controller in zones + for description in VALVE_TYPES + ) + + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] ) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseValve(HydrawiseEntity, ValveEntity): diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 0f49bacd1ef..60a53193acc 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -266,16 +266,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 72e76ef8667..1ef53ad2951 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_BASE, @@ -431,7 +431,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return HyperionOptionsFlow() -class HyperionOptionsFlow(OptionsFlow): +class HyperionOptionsFlow(OptionsFlowWithReload): """Hyperion options flow.""" def _create_client(self) -> client.HyperionClient: diff --git a/homeassistant/components/icloud/services.py b/homeassistant/components/icloud/services.py index dbb843e8216..44a2e5d52f7 100644 --- a/homeassistant/components/icloud/services.py +++ b/homeassistant/components/icloud/services.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify -from .account import IcloudAccount +from .account import IcloudAccount, IcloudConfigEntry from .const import ( ATTR_ACCOUNT, ATTR_DEVICE_NAME, @@ -92,8 +92,10 @@ def lost_device(service: ServiceCall) -> None: def update_account(service: ServiceCall) -> None: """Call the update function of an iCloud account.""" if (account := service.data.get(ATTR_ACCOUNT)) is None: - for account in service.hass.data[DOMAIN].values(): - account.keep_alive() + # Update all accounts when no specific account is provided + entry: IcloudConfigEntry + for entry in service.hass.config_entries.async_loaded_entries(DOMAIN): + entry.runtime_data.keep_alive() else: _get_account(service.hass, account).keep_alive() @@ -102,17 +104,12 @@ def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount: if account_identifier is None: return None - icloud_account: IcloudAccount | None = hass.data[DOMAIN].get(account_identifier) - if icloud_account is None: - for account in hass.data[DOMAIN].values(): - if account.username == account_identifier: - icloud_account = account + entry: IcloudConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if entry.runtime_data.username == account_identifier: + return entry.runtime_data - if icloud_account is None: - raise ValueError( - f"No iCloud account with username or name {account_identifier}" - ) - return icloud_account + raise ValueError(f"No iCloud account with username or name {account_identifier}") @callback diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 644d335bbca..0a3b9bf9af7 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -288,8 +288,10 @@ class ImageView(HomeAssistantView): """Initialize an image view.""" self.component = component - async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: - """Start a GET request.""" + async def _authenticate_request( + self, request: web.Request, entity_id: str + ) -> ImageEntity: + """Authenticate request and return image entity.""" if (image_entity := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound @@ -306,6 +308,31 @@ class ImageView(HomeAssistantView): # Invalid sigAuth or image entity access token raise web.HTTPForbidden + return image_entity + + async def head(self, request: web.Request, entity_id: str) -> web.Response: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + """ + image_entity = await self._authenticate_request(request, entity_id) + + # Don't use `handle` as we don't care about the stream case, we only want + # to verify that the image exists. + try: + image = await _async_get_image(image_entity, IMAGE_TIMEOUT) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError from ex + + return web.Response( + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) + + async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: + """Start a GET request.""" + image_entity = await self._authenticate_request(request, entity_id) return await self.handle(request, image_entity) async def handle( @@ -317,7 +344,11 @@ class ImageView(HomeAssistantView): except (HomeAssistantError, ValueError) as ex: raise web.HTTPInternalServerError from ex - return web.Response(body=image.content, content_type=image.content_type) + return web.Response( + body=image.content, + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) async def async_get_still_stream( diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index bc01476d509..34013c28a18 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==11.2.1"] + "requirements": ["Pillow==11.3.0"] } diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py index fd08955c038..9cde40e01d7 100644 --- a/homeassistant/components/imeon_inverter/const.py +++ b/homeassistant/components/imeon_inverter/const.py @@ -7,3 +7,26 @@ TIMEOUT = 30 PLATFORMS = [ Platform.SENSOR, ] +ATTR_BATTERY_STATUS = ["charging", "discharging", "charged"] +ATTR_INVERTER_STATE = [ + "unsynchronized", + "grid_consumption", + "grid_injection", + "grid_synchronised_but_not_used", +] +ATTR_TIMELINE_STATUS = [ + "com_lost", + "warning_grid", + "warning_pv", + "warning_bat", + "error_ond", + "error_soft", + "error_pv", + "error_grid", + "error_bat", + "good_1", + "info_soft", + "info_ond", + "info_bat", + "info_smartlo", +] diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py index 8342240b9ff..d41e9fd43b2 100644 --- a/homeassistant/components/imeon_inverter/coordinator.py +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -17,7 +17,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import TIMEOUT HUBNAME = "imeon_inverter_hub" -INTERVAL = timedelta(seconds=60) +INTERVAL = 60 _LOGGER = logging.getLogger(__name__) type InverterConfigEntry = ConfigEntry[InverterCoordinator] @@ -44,7 +44,7 @@ class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): hass, _LOGGER, name=HUBNAME, - update_interval=INTERVAL, + update_interval=timedelta(seconds=INTERVAL), config_entry=entry, ) @@ -83,15 +83,12 @@ class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): # Fetch data using distant API try: await self._api.update() - except (ValueError, ClientError) as e: + except (ValueError, TimeoutError, ClientError) as e: raise UpdateFailed(e) from e # Store data for key, val in self._api.storage.items(): - if key == "timeline": - data[key] = val - else: - for sub_key, sub_val in val.items(): - data[f"{key}_{sub_key}"] = sub_val + for sub_key, sub_val in val.items(): + data[f"{key}_{sub_key}"] = sub_val return data diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json index 1c74cf4c745..a4a7edf21a6 100644 --- a/homeassistant/components/imeon_inverter/icons.json +++ b/homeassistant/components/imeon_inverter/icons.json @@ -1,20 +1,20 @@ { "entity": { "sensor": { - "battery_autonomy": { - "default": "mdi:battery-clock" - }, - "battery_charge_time": { - "default": "mdi:battery-charging" - }, "battery_power": { "default": "mdi:battery" }, "battery_soc": { "default": "mdi:battery-charging-100" }, + "battery_status": { + "default": "mdi:battery-alert" + }, "battery_stored": { - "default": "mdi:battery" + "default": "mdi:battery-arrow-up" + }, + "battery_consumed": { + "default": "mdi:battery-arrow-down" }, "grid_current_l1": { "default": "mdi:current-ac" @@ -56,10 +56,7 @@ "default": "mdi:power-socket" }, "meter_power": { - "default": "mdi:power-plug" - }, - "meter_power_protocol": { - "default": "mdi:protocol" + "default": "mdi:meter-electric" }, "output_current_l1": { "default": "mdi:current-ac" @@ -115,35 +112,17 @@ "temp_component_temperature": { "default": "mdi:thermometer" }, - "monitoring_building_consumption": { - "default": "mdi:home-lightning-bolt" - }, - "monitoring_economy_factor": { - "default": "mdi:chart-bar" - }, - "monitoring_grid_consumption": { - "default": "mdi:transmission-tower" - }, - "monitoring_grid_injection": { - "default": "mdi:transmission-tower-export" - }, - "monitoring_grid_power_flow": { - "default": "mdi:power-plug" - }, "monitoring_self_consumption": { "default": "mdi:percent" }, "monitoring_self_sufficiency": { "default": "mdi:percent" }, - "monitoring_solar_production": { - "default": "mdi:solar-power" - }, "monitoring_minute_building_consumption": { "default": "mdi:home-lightning-bolt" }, "monitoring_minute_grid_consumption": { - "default": "mdi:transmission-tower" + "default": "mdi:transmission-tower-import" }, "monitoring_minute_grid_injection": { "default": "mdi:transmission-tower-export" @@ -153,6 +132,43 @@ }, "monitoring_minute_solar_production": { "default": "mdi:solar-power" + }, + "timeline_type_msg": { + "default": "mdi:check-circle", + "state": { + "com_lost": "mdi:lan-disconnect", + "warning_grid": "mdi:alert-circle", + "warning_pv": "mdi:alert-circle", + "warning_bat": "mdi:alert-circle", + "error_ond": "mdi:close-octagon", + "error_soft": "mdi:close-octagon", + "error_pv": "mdi:close-octagon", + "error_grid": "mdi:close-octagon", + "error_bat": "mdi:close-octagon", + "good_1": "mdi:check-circle", + "info_soft": "mdi:information-slab-circle", + "info_ond": "mdi:information-slab-circle", + "info_bat": "mdi:information-slab-circle", + "info_smartlo": "mdi:information-slab-circle" + } + }, + "energy_pv": { + "default": "mdi:solar-power" + }, + "energy_grid_injected": { + "default": "mdi:transmission-tower-export" + }, + "energy_grid_consumed": { + "default": "mdi:transmission-tower-import" + }, + "energy_building_consumption": { + "default": "mdi:home-lightning-bolt-outline" + }, + "energy_battery_stored": { + "default": "mdi:battery-arrow-up-outline" + }, + "energy_battery_consumed": { + "default": "mdi:battery-arrow-down-outline" } } } diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json index 1398521dc45..a9a37f3fd9c 100644 --- a/homeassistant/components/imeon_inverter/manifest.json +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["imeon_inverter_api==0.3.12"], + "requirements": ["imeon_inverter_api==0.3.14"], "ssdp": [ { "manufacturer": "IMEON", diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py index e1d05d0ecf6..21aa37a0523 100644 --- a/homeassistant/components/imeon_inverter/sensor.py +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -18,12 +18,12 @@ from homeassistant.const import ( UnitOfFrequency, UnitOfPower, UnitOfTemperature, - UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from .const import ATTR_BATTERY_STATUS, ATTR_INVERTER_STATE, ATTR_TIMELINE_STATUS from .coordinator import InverterCoordinator from .entity import InverterEntity @@ -34,20 +34,6 @@ _LOGGER = logging.getLogger(__name__) SENSOR_DESCRIPTIONS = ( # Battery - SensorEntityDescription( - key="battery_autonomy", - translation_key="battery_autonomy", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="battery_charge_time", - translation_key="battery_charge_time", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - ), SensorEntityDescription( key="battery_power", translation_key="battery_power", @@ -62,11 +48,24 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key="battery_status", + translation_key="battery_status", + device_class=SensorDeviceClass.ENUM, + options=ATTR_BATTERY_STATUS, + ), SensorEntityDescription( key="battery_stored", translation_key="battery_stored", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_consumed", + translation_key="battery_consumed", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), # Grid @@ -163,6 +162,12 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key="manager_inverter_state", + translation_key="manager_inverter_state", + device_class=SensorDeviceClass.ENUM, + options=ATTR_INVERTER_STATE, + ), # Meter SensorEntityDescription( key="meter_power", @@ -171,13 +176,6 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( - key="meter_power_protocol", - translation_key="meter_power_protocol", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), # AC Output SensorEntityDescription( key="output_current_l1", @@ -260,16 +258,16 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key="pv_consumed", translation_key="pv_consumed", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pv_injected", translation_key="pv_injected", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pv_power_1", @@ -308,64 +306,17 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, ), # Monitoring (data over the last 24 hours) - SensorEntityDescription( - key="monitoring_building_consumption", - translation_key="monitoring_building_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_economy_factor", - translation_key="monitoring_economy_factor", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_consumption", - translation_key="monitoring_grid_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_injection", - translation_key="monitoring_grid_injection", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_power_flow", - translation_key="monitoring_grid_power_flow", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), SensorEntityDescription( key="monitoring_self_consumption", translation_key="monitoring_self_consumption", native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, suggested_display_precision=2, ), SensorEntityDescription( key="monitoring_self_sufficiency", translation_key="monitoring_self_sufficiency", native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_solar_production", - translation_key="monitoring_solar_production", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, ), @@ -410,6 +361,62 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ), + # Timeline + SensorEntityDescription( + key="timeline_type_msg", + translation_key="timeline_type_msg", + device_class=SensorDeviceClass.ENUM, + options=ATTR_TIMELINE_STATUS, + ), + # Daily energy counters + SensorEntityDescription( + key="energy_pv", + translation_key="energy_pv", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_grid_injected", + translation_key="energy_grid_injected", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_grid_consumed", + translation_key="energy_grid_consumed", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_building_consumption", + translation_key="energy_building_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_battery_stored", + translation_key="energy_battery_stored", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="energy_battery_consumed", + translation_key="energy_battery_consumed", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + ), ) diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json index 218e1c4e4aa..66d0472b89a 100644 --- a/homeassistant/components/imeon_inverter/strings.json +++ b/homeassistant/components/imeon_inverter/strings.json @@ -29,21 +29,26 @@ }, "entity": { "sensor": { - "battery_autonomy": { - "name": "Battery autonomy" - }, - "battery_charge_time": { - "name": "Battery charge time" - }, "battery_power": { "name": "Battery power" }, "battery_soc": { "name": "Battery state of charge" }, + "battery_status": { + "name": "Battery status", + "state": { + "charged": "Charged", + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]" + } + }, "battery_stored": { "name": "Battery stored" }, + "battery_consumed": { + "name": "Battery consumed" + }, "grid_current_l1": { "name": "Grid current L1" }, @@ -83,12 +88,18 @@ "inverter_injection_power_limit": { "name": "Injection power limit" }, + "manager_inverter_state": { + "name": "Inverter state", + "state": { + "unsynchronized": "Unsynchronized", + "grid_consumption": "Grid consumption", + "grid_injection": "Grid injection", + "grid_synchronised_but_not_used": "Grid unsynchronized but used" + } + }, "meter_power": { "name": "Meter power" }, - "meter_power_protocol": { - "name": "Meter power protocol" - }, "output_current_l1": { "name": "Output current L1" }, @@ -143,44 +154,64 @@ "temp_component_temperature": { "name": "Component temperature" }, - "monitoring_building_consumption": { - "name": "Monitoring building consumption" - }, - "monitoring_economy_factor": { - "name": "Monitoring economy factor" - }, - "monitoring_grid_consumption": { - "name": "Monitoring grid consumption" - }, - "monitoring_grid_injection": { - "name": "Monitoring grid injection" - }, - "monitoring_grid_power_flow": { - "name": "Monitoring grid power flow" - }, "monitoring_self_consumption": { - "name": "Monitoring self-consumption" + "name": "Self-consumption" }, "monitoring_self_sufficiency": { - "name": "Monitoring self-sufficiency" - }, - "monitoring_solar_production": { - "name": "Monitoring solar production" + "name": "Self-sufficiency" }, "monitoring_minute_building_consumption": { - "name": "Monitoring building consumption (minute)" + "name": "Building consumption" }, "monitoring_minute_grid_consumption": { - "name": "Monitoring grid consumption (minute)" + "name": "Grid consumption" }, "monitoring_minute_grid_injection": { - "name": "Monitoring grid injection (minute)" + "name": "Grid injection" }, "monitoring_minute_grid_power_flow": { - "name": "Monitoring grid power flow (minute)" + "name": "Grid power flow" }, "monitoring_minute_solar_production": { - "name": "Monitoring solar production (minute)" + "name": "Solar production" + }, + "timeline_type_msg": { + "name": "Timeline status", + "state": { + "com_lost": "Communication lost.", + "warning_grid": "Power grid warning detected.", + "warning_pv": "PV system warning detected.", + "warning_bat": "Battery warning detected.", + "error_ond": "Inverter error detected.", + "error_soft": "Software error detected.", + "error_pv": "PV system error detected.", + "error_grid": "Power grid error detected.", + "error_bat": "Battery error detected.", + "good_1": "System operating normally.", + "web_account": "Web account notification.", + "info_soft": "Software information available.", + "info_ond": "Inverter information available.", + "info_bat": "Battery information available.", + "info_smartlo": "Smart load information available." + } + }, + "energy_pv": { + "name": "Today PV energy" + }, + "energy_grid_injected": { + "name": "Today grid-injected energy" + }, + "energy_grid_consumed": { + "name": "Today grid-consumed energy" + }, + "energy_building_consumption": { + "name": "Today building consumption" + }, + "energy_battery_stored": { + "name": "Today battery-stored energy" + }, + "energy_battery_consumed": { + "name": "Today battery-consumed energy" } } } diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index 29aa19a4b56..0265c6c2ec0 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "hydrological_alert": { + "default": "mdi:alert-octagon-outline" + }, + "water_flow": { + "default": "mdi:waves-arrow-right" + }, "water_level": { "default": "mdi:waves" }, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 42d536da8f5..145690487d7 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.1.0"] + "requirements": ["imgw_pib==1.5.3"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 7871006b2ae..7084889220c 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -4,7 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any +from imgw_pib.const import HYDROLOGICAL_ALERTS_MAP, NO_ALERT from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( @@ -14,7 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfLength, UnitOfTemperature +from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,14 +30,45 @@ from .entity import ImgwPibEntity PARALLEL_UPDATES = 0 +def gen_alert_attributes(data: HydrologicalData) -> dict[str, Any] | None: + """Generate attributes for the alert entity.""" + if data.hydrological_alert.value == NO_ALERT: + return None + + return { + "level": data.hydrological_alert.level, + "probability": data.hydrological_alert.probability, + "valid_from": data.hydrological_alert.valid_from, + "valid_to": data.hydrological_alert.valid_to, + } + + @dataclass(frozen=True, kw_only=True) class ImgwPibSensorEntityDescription(SensorEntityDescription): """IMGW-PIB sensor entity description.""" value: Callable[[HydrologicalData], StateType] + attrs: Callable[[HydrologicalData], dict[str, Any] | None] | None = None SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="hydrological_alert", + translation_key="hydrological_alert", + device_class=SensorDeviceClass.ENUM, + options=list(HYDROLOGICAL_ALERTS_MAP.values()), + value=lambda data: data.hydrological_alert.value, + attrs=gen_alert_attributes, + ), + ImgwPibSensorEntityDescription( + key="water_flow", + translation_key="water_flow", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value=lambda data: data.water_flow.value, + ), ImgwPibSensorEntityDescription( key="water_level", translation_key="water_level", @@ -100,3 +133,11 @@ class ImgwPibSensorEntity(ImgwPibEntity, SensorEntity): def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.entity_description.value(self.coordinator.data) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.attrs: + return self.entity_description.attrs(self.coordinator.data) + + return None diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 9b7f132da6f..d55c134ba3b 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -21,6 +21,46 @@ }, "entity": { "sensor": { + "hydrological_alert": { + "name": "Hydrological alert", + "state": { + "no_alert": "No alert", + "exceeding_the_warning_level": "Exceeding the warning level", + "hydrological_drought": "Hydrological drought", + "rapid_water_level_rise": "Rapid water level rise" + }, + "state_attributes": { + "level": { + "name": "Level", + "state": { + "none": "None", + "orange": "Orange", + "red": "Red", + "yellow": "Yellow" + } + }, + "options": { + "state": { + "no_alert": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::no_alert%]", + "exceeding_the_warning_level": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::exceeding_the_warning_level%]", + "hydrological_drought": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::hydrological_drought%]", + "rapid_water_level_rise": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::rapid_water_level_rise%]" + } + }, + "probability": { + "name": "Probability" + }, + "valid_from": { + "name": "Valid from" + }, + "valid_to": { + "name": "Valid to" + } + } + }, + "water_flow": { + "name": "Water flow" + }, "water_level": { "name": "Water level" }, diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py index d40615dbe88..996e4f3ad8c 100644 --- a/homeassistant/components/immich/__init__.py +++ b/homeassistant/components/immich/__init__.py @@ -16,13 +16,25 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up immich integration.""" + await async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: """Set up Immich from a config entry.""" diff --git a/homeassistant/components/immich/icons.json b/homeassistant/components/immich/icons.json index 15bac6370a6..aefce3ed615 100644 --- a/homeassistant/components/immich/icons.json +++ b/homeassistant/components/immich/icons.json @@ -11,5 +11,10 @@ "default": "mdi:file-video" } } + }, + "services": { + "upload_file": { + "service": "mdi:upload" + } } } diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 80dcd87cd88..6fa8210b878 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "silver", - "requirements": ["aioimmich==0.10.1"] + "requirements": ["aioimmich==0.11.1"] } diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index caf8264895b..008a807c0d2 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -5,6 +5,7 @@ from __future__ import annotations from logging import getLogger from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse +from aioimmich.assets.models import ImmichAsset from aioimmich.exceptions import ImmichError from homeassistant.components.http import HomeAssistantView @@ -83,6 +84,10 @@ class ImmichMediaSource(MediaSource): self, item: MediaSourceItem, entries: list[ConfigEntry] ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" + + # -------------------------------------------------------- + # root level, render immich instances + # -------------------------------------------------------- if not item.identifier: LOGGER.debug("Render all Immich instances") return [ @@ -97,6 +102,10 @@ class ImmichMediaSource(MediaSource): ) for entry in entries ] + + # -------------------------------------------------------- + # 1st level, render collections overview + # -------------------------------------------------------- identifier = ImmichMediaSourceIdentifier(item.identifier) entry: ImmichConfigEntry | None = ( self.hass.config_entries.async_entry_for_domain_unique_id( @@ -111,50 +120,127 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums", + identifier=f"{identifier.unique_id}|{collection}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title="albums", + title=collection, can_play=False, can_expand=True, ) + for collection in ("albums", "people", "tags") ] + # -------------------------------------------------------- + # 2nd level, render collection + # -------------------------------------------------------- if identifier.collection_id is None: - LOGGER.debug("Render all albums for %s", entry.title) + if identifier.collection == "albums": + LOGGER.debug("Render all albums for %s", entry.title) + try: + albums = await immich_api.albums.async_get_all_albums() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|albums|{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.album_name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", + ) + for album in albums + ] + + if identifier.collection == "tags": + LOGGER.debug("Render all tags for %s", entry.title) + try: + tags = await immich_api.tags.async_get_all_tags() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|tags|{tag.tag_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=tag.name, + can_play=False, + can_expand=True, + ) + for tag in tags + ] + + if identifier.collection == "people": + LOGGER.debug("Render all people for %s", entry.title) + try: + people = await immich_api.people.async_get_all_people() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|people|{person.person_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=person.name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{person.person_id}/person/image/jpg", + ) + for person in people + ] + + # -------------------------------------------------------- + # final level, render assets + # -------------------------------------------------------- + assert identifier.collection_id is not None + assets: list[ImmichAsset] = [] + if identifier.collection == "albums": + LOGGER.debug( + "Render all assets of album %s for %s", + identifier.collection_id, + entry.title, + ) try: - albums = await immich_api.albums.async_get_all_albums() + album_info = await immich_api.albums.async_get_album_info( + identifier.collection_id + ) + assets = album_info.assets except ImmichError: return [] - return [ - BrowseMediaSource( - domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums|{album.album_id}", - media_class=MediaClass.DIRECTORY, - media_content_type=MediaClass.IMAGE, - title=album.album_name, - can_play=False, - can_expand=True, - thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", - ) - for album in albums - ] - - LOGGER.debug( - "Render all assets of album %s for %s", - identifier.collection_id, - entry.title, - ) - try: - album_info = await immich_api.albums.async_get_album_info( - identifier.collection_id + elif identifier.collection == "tags": + LOGGER.debug( + "Render all assets with tag %s", + identifier.collection_id, ) - except ImmichError: - return [] + try: + assets = await immich_api.search.async_get_all_by_tag_ids( + [identifier.collection_id] + ) + except ImmichError: + return [] + + elif identifier.collection == "people": + LOGGER.debug( + "Render all assets for person %s", + identifier.collection_id, + ) + try: + assets = await immich_api.search.async_get_all_by_person_ids( + [identifier.collection_id] + ) + except ImmichError: + return [] ret: list[BrowseMediaSource] = [] - for asset in album_info.assets: + for asset in assets: if not (mime_type := asset.original_mime_type) or not mime_type.startswith( ("image/", "video/") ): @@ -173,7 +259,8 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}|albums|" + f"{identifier.unique_id}|" + f"{identifier.collection}|" f"{identifier.collection_id}|" f"{asset.asset_id}|" f"{asset.original_file_name}|" @@ -257,7 +344,10 @@ class ImmichMediaView(HomeAssistantView): # web response for images try: - image = await immich_api.assets.async_view_asset(asset_id, size) + if size == "person": + image = await immich_api.people.async_get_person_thumbnail(asset_id) + else: + image = await immich_api.assets.async_view_asset(asset_id, size) except ImmichError as exc: raise HTTPNotFound from exc return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") diff --git a/homeassistant/components/immich/services.py b/homeassistant/components/immich/services.py new file mode 100644 index 00000000000..fffd5d9110b --- /dev/null +++ b/homeassistant/components/immich/services.py @@ -0,0 +1,98 @@ +"""Services for the Immich integration.""" + +import logging + +from aioimmich.exceptions import ImmichError +import voluptuous as vol + +from homeassistant.components.media_source import async_resolve_media +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import MediaSelector + +from .const import DOMAIN +from .coordinator import ImmichConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONF_ALBUM_ID = "album_id" +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_FILE = "file" + +SERVICE_UPLOAD_FILE = "upload_file" +SERVICE_SCHEMA_UPLOAD_FILE = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): str, + vol.Required(CONF_FILE): MediaSelector({"accept": ["image/*", "video/*"]}), + vol.Optional(CONF_ALBUM_ID): str, + } +) + + +async def _async_upload_file(service_call: ServiceCall) -> None: + """Call immich upload file service.""" + _LOGGER.debug( + "Executing service %s with arguments %s", + service_call.service, + service_call.data, + ) + hass = service_call.hass + target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry( + service_call.data[CONF_CONFIG_ENTRY_ID] + ) + source_media_id = service_call.data[CONF_FILE]["media_content_id"] + + if not target_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + ) + + if target_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + ) + + media = await async_resolve_media(hass, source_media_id, None) + if media.path is None: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="only_local_media_supported" + ) + + coordinator = target_entry.runtime_data + + if target_album := service_call.data.get(CONF_ALBUM_ID): + try: + await coordinator.api.albums.async_get_album_info(target_album, True) + except ImmichError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="album_not_found", + translation_placeholders={"album_id": target_album, "error": str(ex)}, + ) from ex + + try: + upload_result = await coordinator.api.assets.async_upload_asset(str(media.path)) + if target_album: + await coordinator.api.albums.async_add_assets_to_album( + target_album, [upload_result.asset_id] + ) + except ImmichError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="upload_failed", + translation_placeholders={"file": str(media.path), "error": str(ex)}, + ) from ex + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for immich integration.""" + + hass.services.async_register( + DOMAIN, + SERVICE_UPLOAD_FILE, + _async_upload_file, + SERVICE_SCHEMA_UPLOAD_FILE, + ) diff --git a/homeassistant/components/immich/services.yaml b/homeassistant/components/immich/services.yaml new file mode 100644 index 00000000000..7924a6a112c --- /dev/null +++ b/homeassistant/components/immich/services.yaml @@ -0,0 +1,18 @@ +upload_file: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: immich + file: + required: true + selector: + media: + accept: + - image/* + - video/* + album_id: + required: false + selector: + text: diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json index 83ee7574630..90fccfa1bb1 100644 --- a/homeassistant/components/immich/strings.json +++ b/homeassistant/components/immich/strings.json @@ -74,5 +74,42 @@ "name": "Version" } } + }, + "services": { + "upload_file": { + "name": "Upload file", + "description": "Uploads a file to your Immich instance.", + "fields": { + "config_entry_id": { + "name": "Immich instance", + "description": "The Immich instance where to upload the file." + }, + "file": { + "name": "File", + "description": "The path to the file to be uploaded." + }, + "album_id": { + "name": "Album ID", + "description": "The album in which the file should be placed after uploading." + } + } + } + }, + "exceptions": { + "config_entry_not_found": { + "message": "Config entry not found." + }, + "config_entry_not_loaded": { + "message": "Config entry not loaded." + }, + "only_local_media_supported": { + "message": "Only local media files are currently supported." + }, + "album_not_found": { + "message": "Album with ID `{album_id}` not found ({error})." + }, + "upload_failed": { + "message": "Upload of file `{file}` failed ({error})." + } } } diff --git a/homeassistant/components/immich/update.py b/homeassistant/components/immich/update.py index 9955e355c96..e0af5c1c67f 100644 --- a/homeassistant/components/immich/update.py +++ b/homeassistant/components/immich/update.py @@ -44,7 +44,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity): return self.coordinator.data.server_about.version @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Available new immich server version.""" assert self.coordinator.data.server_version_check return self.coordinator.data.server_version_check.release_version diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 9c73c4d970f..721c462c800 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -42,10 +42,19 @@ "local_name": "Ink@IAM-T1", "connectable": true }, + { + "local_name": "Ink@IAM-T2", + "connectable": true + }, { "manufacturer_id": 12628, "manufacturer_data_start": [65, 67, 45], "connectable": true + }, + { + "manufacturer_id": 12884, + "manufacturer_data_start": [0, 98, 0], + "connectable": false } ], "codeowners": ["@bdraco"], @@ -53,5 +62,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.16.2"] + "requirements": ["inkbird-ble==1.1.0"] } diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 998bf35cd82..4928b4325d1 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_MODE, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, + MAX_LENGTH_STATE_STATE, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -51,8 +52,12 @@ STORAGE_VERSION = 1 STORAGE_FIELDS: VolDictType = { vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL, ""): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -84,8 +89,12 @@ CONFIG_SCHEMA = vol.Schema( lambda value: value or {}, { vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 3a15d667ca7..dedbc9c4fa9 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -18,16 +18,16 @@ } }, "hubv1": { - "title": "Insteon Hub Version 1", - "description": "Configure the Insteon Hub Version 1 (pre-2014).", + "title": "Insteon Hub version 1", + "description": "Configure the Insteon Hub version 1 (pre-2014).", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" } }, "hubv2": { - "title": "Insteon Hub Version 2", - "description": "Configure the Insteon Hub Version 2.", + "title": "Insteon Hub version 2", + "description": "Configure the Insteon Hub version 2.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", @@ -144,7 +144,7 @@ }, "reload": { "name": "[%key:common::action::reload%]", - "description": "If enabled, all current records are cleared from memory (does not effect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." + "description": "If enabled, all current records are cleared from memory (does not affect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." } } }, diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 0a64ce7140f..82f44578aed 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_SOURCE_SENSOR +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Integration from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -29,20 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the integration config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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 + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" # Remove device link for entry, the source device may have changed. diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 28cd280f7f8..329abdbea87 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -147,6 +147,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Integration.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index df5342111a7..49a032899be 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -40,8 +40,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -246,11 +245,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None @@ -265,6 +259,7 @@ async def async_setup_entry( round_digits = int(round_digits) integral = IntegrationSensor( + hass, integration_method=config_entry.options[CONF_METHOD], name=config_entry.title, round_digits=round_digits, @@ -272,7 +267,6 @@ async def async_setup_entry( unique_id=config_entry.entry_id, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -287,6 +281,7 @@ async def async_setup_platform( ) -> None: """Set up the integration sensor.""" integral = IntegrationSensor( + hass, integration_method=config[CONF_METHOD], name=config.get(CONF_NAME), round_digits=config.get(CONF_ROUND_DIGITS), @@ -308,6 +303,7 @@ class IntegrationSensor(RestoreSensor): def __init__( self, + hass: HomeAssistant, *, integration_method: str, name: str | None, @@ -317,7 +313,6 @@ class IntegrationSensor(RestoreSensor): unit_prefix: str | None, unit_time: UnitOfTime, max_sub_interval: timedelta | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the integration sensor.""" self._attr_unique_id = unique_id @@ -335,7 +330,10 @@ class IntegrationSensor(RestoreSensor): self._attr_icon = "mdi:chart-histogram" self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._max_sub_interval: timedelta | None = ( None # disable time based integration if max_sub_interval is None or max_sub_interval.total_seconds() == 0 @@ -465,7 +463,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state update when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -474,13 +472,17 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) @callback def _integrate_on_state_update_with_max_sub_interval( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -491,7 +493,9 @@ class IntegrationSensor(RestoreSensor): """ self._cancel_max_sub_interval_exceeded_callback() try: - self._integrate_on_state_change(old_last_reported, old_state, new_state) + self._integrate_on_state_change( + old_timestamp, new_timestamp, old_state, new_state + ) self._last_integration_trigger = _IntegrationTrigger.StateEvent self._last_integration_time = datetime.now(tz=UTC) finally: @@ -505,7 +509,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state change.""" return self._integrate_on_state_change( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -514,12 +518,16 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report.""" return self._integrate_on_state_change( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) def _integrate_on_state_change( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -533,16 +541,17 @@ class IntegrationSensor(RestoreSensor): if old_state: # state has changed, we recover old_state from the event + new_timestamp = new_state.last_updated old_state_state = old_state.state - old_last_reported = old_state.last_reported + old_timestamp = old_state.last_reported else: - # event state reported without any state change + # first state or event state reported without any state change old_state_state = new_state.state self._attr_available = True self._derive_and_set_attributes_from_state(new_state) - if old_last_reported is None and old_state is None: + if old_timestamp is None and old_state is None: self.async_write_ha_state() return @@ -553,11 +562,12 @@ class IntegrationSensor(RestoreSensor): return if TYPE_CHECKING: - assert old_last_reported is not None + assert new_timestamp is not None + assert old_timestamp is not None elapsed_seconds = Decimal( - (new_state.last_reported - old_last_reported).total_seconds() + (new_timestamp - old_timestamp).total_seconds() if self._last_integration_trigger == _IntegrationTrigger.StateEvent - else (new_state.last_reported - self._last_integration_time).total_seconds() + else (new_timestamp - self._last_integration_time).total_seconds() ) area = self._method.calculate_area_with_two_states(elapsed_seconds, *states) diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 75253099cdb..48a89f5a96a 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.3.0", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.3.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 7a0cf8eaa53..01ce0918459 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -9,9 +9,7 @@ from pynecil import IronOSUpdate, Pynecil from homeassistant.const import 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 homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -33,8 +31,6 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) @@ -42,19 +38,15 @@ IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up IronOS firmware update coordinator.""" - - session = async_get_clientsession(hass) - github = IronOSUpdate(session) - - hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) - await hass.data[IRON_OS_KEY].async_request_refresh() - return True - - async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool: """Set up IronOS from a config entry.""" + if IRON_OS_KEY not in hass.data: + session = async_get_clientsession(hass) + github = IronOSUpdate(session) + + hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) + await hass.data[IRON_OS_KEY].async_request_refresh() + if TYPE_CHECKING: assert entry.unique_id @@ -77,4 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if not hass.config_entries.async_loaded_entries(DOMAIN): + await hass.data[IRON_OS_KEY].async_shutdown() + hass.data.pop(IRON_OS_KEY) + return unload_ok diff --git a/homeassistant/components/iron_os/const.py b/homeassistant/components/iron_os/const.py index 34889636808..0ed645f8f7b 100644 --- a/homeassistant/components/iron_os/const.py +++ b/homeassistant/components/iron_os/const.py @@ -10,4 +10,8 @@ OHM = "Ω" DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533" MAX_TEMP: int = 450 +MAX_TEMP_F: int = 850 MIN_TEMP: int = 10 +MIN_TEMP_F: int = 50 +MIN_BOOST_TEMP: int = 250 +MIN_BOOST_TEMP_F: int = 480 diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 99c688ea855..7214db0a12f 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -168,7 +168,9 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): if self.device.is_connected and characteristics: try: - return await self.device.get_settings(list(characteristics)) + return await self.device.get_settings( + list(characteristics | {CharSetting.TEMP_UNIT}) + ) except CommunicationError as e: _LOGGER.debug("Failed to fetch settings", exc_info=e) diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 695b9d16849..039ad61cbf4 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -209,6 +209,12 @@ "state": { "off": "mdi:card-bulleted-off-outline" } + }, + "boost": { + "default": "mdi:thermometer-high", + "state": { + "off": "mdi:thermometer-off" + } } } } diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 58cbdaa3bc6..be2309ab340 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.1.0"] + "requirements": ["pynecil==4.1.1"] } diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 6ad5947cb6f..71d340148ff 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -6,10 +6,9 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse +from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse, TempUnit from homeassistant.components.number import ( - DEFAULT_MAX_VALUE, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -24,9 +23,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter from . import IronOSConfigEntry -from .const import MAX_TEMP, MIN_TEMP +from .const import ( + MAX_TEMP, + MAX_TEMP_F, + MIN_BOOST_TEMP, + MIN_BOOST_TEMP_F, + MIN_TEMP, + MIN_TEMP_F, +) from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -38,9 +45,10 @@ class IronOSNumberEntityDescription(NumberEntityDescription): """Describes IronOS number entity.""" value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None] - max_value_fn: Callable[[LiveDataResponse], float | int] | None = None characteristic: CharSetting raw_value_fn: Callable[[float], float | int] | None = None + native_max_value_f: float | None = None + native_min_value_f: float | None = None class PinecilNumber(StrEnum): @@ -74,44 +82,6 @@ def multiply(value: float | None, multiplier: float) -> float | None: PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( - IronOSNumberEntityDescription( - key=PinecilNumber.SETPOINT_TEMP, - translation_key=PinecilNumber.SETPOINT_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda data, _: data.setpoint_temp, - characteristic=CharSetting.SETPOINT_TEMP, - mode=NumberMode.BOX, - native_min_value=MIN_TEMP, - native_step=5, - max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP), - ), - IronOSNumberEntityDescription( - key=PinecilNumber.SLEEP_TEMP, - translation_key=PinecilNumber.SLEEP_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda _, settings: settings.get("sleep_temp"), - characteristic=CharSetting.SLEEP_TEMP, - mode=NumberMode.BOX, - native_min_value=MIN_TEMP, - native_max_value=MAX_TEMP, - native_step=10, - entity_category=EntityCategory.CONFIG, - ), - IronOSNumberEntityDescription( - key=PinecilNumber.BOOST_TEMP, - translation_key=PinecilNumber.BOOST_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda _, settings: settings.get("boost_temp"), - characteristic=CharSetting.BOOST_TEMP, - mode=NumberMode.BOX, - native_min_value=0, - native_max_value=MAX_TEMP, - native_step=10, - entity_category=EntityCategory.CONFIG, - ), IronOSNumberEntityDescription( key=PinecilNumber.QC_MAX_VOLTAGE, translation_key=PinecilNumber.QC_MAX_VOLTAGE, @@ -296,32 +266,6 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), - IronOSNumberEntityDescription( - key=PinecilNumber.TEMP_INCREMENT_SHORT, - translation_key=PinecilNumber.TEMP_INCREMENT_SHORT, - value_fn=(lambda _, settings: settings.get("temp_increment_short")), - characteristic=CharSetting.TEMP_INCREMENT_SHORT, - raw_value_fn=lambda value: value, - mode=NumberMode.BOX, - native_min_value=1, - native_max_value=50, - native_step=1, - entity_category=EntityCategory.CONFIG, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), - IronOSNumberEntityDescription( - key=PinecilNumber.TEMP_INCREMENT_LONG, - translation_key=PinecilNumber.TEMP_INCREMENT_LONG, - value_fn=(lambda _, settings: settings.get("temp_increment_long")), - characteristic=CharSetting.TEMP_INCREMENT_LONG, - raw_value_fn=lambda value: value, - mode=NumberMode.BOX, - native_min_value=5, - native_max_value=90, - native_step=5, - entity_category=EntityCategory.CONFIG, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), ) PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( @@ -341,6 +285,82 @@ PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( ), ) +""" +The `device_class` attribute was removed from the `setpoint_temperature`, `sleep_temperature`, and `boost_temp` entities. +These entities represent user-defined input values, not measured temperatures, and their +interpretation depends on the device's current unit configuration. Applying a device_class +results in automatic unit conversions, which introduce rounding errors due to the use of integers. +This can prevent the correct value from being set, as the input is modified during synchronization with the device. +""" +PINECIL_TEMP_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( + IronOSNumberEntityDescription( + key=PinecilNumber.SLEEP_TEMP, + translation_key=PinecilNumber.SLEEP_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda _, settings: settings.get("sleep_temp"), + characteristic=CharSetting.SLEEP_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_min_value_f=MIN_TEMP_F, + native_max_value_f=MAX_TEMP_F, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.BOOST_TEMP, + translation_key=PinecilNumber.BOOST_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda _, settings: settings.get("boost_temp"), + characteristic=CharSetting.BOOST_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_BOOST_TEMP, + native_min_value_f=MIN_BOOST_TEMP_F, + native_max_value=MAX_TEMP, + native_max_value_f=MAX_TEMP_F, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_SHORT, + translation_key=PinecilNumber.TEMP_INCREMENT_SHORT, + value_fn=(lambda _, settings: settings.get("temp_increment_short")), + characteristic=CharSetting.TEMP_INCREMENT_SHORT, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=1, + native_max_value=50, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_LONG, + translation_key=PinecilNumber.TEMP_INCREMENT_LONG, + value_fn=(lambda _, settings: settings.get("temp_increment_long")), + characteristic=CharSetting.TEMP_INCREMENT_LONG, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=5, + native_max_value=90, + native_step=5, + entity_category=EntityCategory.CONFIG, + ), +) + +PINECIL_SETPOINT_NUMBER_DESCRIPTION = IronOSNumberEntityDescription( + key=PinecilNumber.SETPOINT_TEMP, + translation_key=PinecilNumber.SETPOINT_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data, _: data.setpoint_temp, + characteristic=CharSetting.SETPOINT_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_min_value_f=MIN_TEMP_F, + native_max_value_f=MAX_TEMP_F, + native_step=5, +) + async def async_setup_entry( hass: HomeAssistant, @@ -354,9 +374,18 @@ async def async_setup_entry( if coordinators.live_data.v223_features: descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223 - async_add_entities( + entities = [ IronOSNumberEntity(coordinators, description) for description in descriptions + ] + + entities.extend( + IronOSTemperatureNumberEntity(coordinators, description) + for description in PINECIL_TEMP_NUMBER_DESCRIPTIONS ) + entities.append( + IronOSSetpointNumberEntity(coordinators, PINECIL_SETPOINT_NUMBER_DESCRIPTION) + ) + async_add_entities(entities) class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): @@ -388,15 +417,6 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): self.coordinator.data, self.settings.data ) - @property - def native_max_value(self) -> float: - """Return sensor state.""" - - if self.entity_description.max_value_fn is not None: - return self.entity_description.max_value_fn(self.coordinator.data) - - return self.entity_description.native_max_value or DEFAULT_MAX_VALUE - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -407,3 +427,70 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): ) ) await self.settings.async_request_refresh() + + +class IronOSTemperatureNumberEntity(IronOSNumberEntity): + """Implementation of a IronOS temperature number entity.""" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + + return ( + UnitOfTemperature.FAHRENHEIT + if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT + else UnitOfTemperature.CELSIUS + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + + return ( + self.entity_description.native_min_value_f + if self.entity_description.native_min_value_f + and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT + else super().native_min_value + ) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + + return ( + self.entity_description.native_max_value_f + if self.entity_description.native_max_value_f + and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT + else super().native_max_value + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if ( + self.entity_description.key is PinecilNumber.BOOST_TEMP + and self.native_value == 0 + ): + return False + return super().available + + +class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity): + """IronOS setpoint temperature entity.""" + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + + return ( + min( + TemperatureConverter.convert( + float(max_tip_c), + UnitOfTemperature.CELSIUS, + self.native_unit_of_measurement, + ), + super().native_max_value, + ) + if (max_tip_c := self.coordinator.data.max_tip_temp_ability) is not None + else super().native_max_value + ) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 8a3d9cc5366..18464dc6dd2 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -278,6 +278,9 @@ }, "calibrate_cjc": { "name": "Calibrate CJC" + }, + "boost": { + "name": "Boost" } } }, diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py index 124b670048a..f1f189d83b3 100644 --- a/homeassistant/components/iron_os/switch.py +++ b/homeassistant/components/iron_os/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pynecil import CharSetting, SettingsDataResponse +from pynecil import CharSetting, SettingsDataResponse, TempUnit from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IronOSConfigEntry +from .const import MIN_BOOST_TEMP, MIN_BOOST_TEMP_F from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -39,6 +40,7 @@ class IronOSSwitch(StrEnum): INVERT_BUTTONS = "invert_buttons" DISPLAY_INVERT = "display_invert" CALIBRATE_CJC = "calibrate_cjc" + BOOST = "boost" SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( @@ -94,6 +96,13 @@ SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.BOOST, + translation_key=IronOSSwitch.BOOST, + characteristic=CharSetting.BOOST_TEMP, + is_on_fn=lambda x: bool(x.get("boost_temp")), + entity_category=EntityCategory.CONFIG, + ), ) @@ -136,7 +145,15 @@ class IronOSSwitchEntity(IronOSBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.settings.write(self.entity_description.characteristic, True) + if self.entity_description.key is IronOSSwitch.BOOST: + await self.settings.write( + self.entity_description.characteristic, + MIN_BOOST_TEMP_F + if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT + else MIN_BOOST_TEMP, + ) + else: + await self.settings.write(self.entity_description.characteristic, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/iskra/strings.json b/homeassistant/components/iskra/strings.json index 5818cdfa1db..ee62974c90d 100644 --- a/homeassistant/components/iskra/strings.json +++ b/homeassistant/components/iskra/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Configure Iskra Device", - "description": "Enter the IP address of your Iskra Device and select protocol.", + "title": "Configure Iskra device", + "description": "Enter the IP address of your Iskra device and select protocol.", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -12,7 +12,7 @@ } }, "authentication": { - "title": "Configure Rest API Credentials", + "title": "Configure REST API credentials", "description": "Enter username and password", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -44,7 +44,7 @@ "selector": { "protocol": { "options": { - "rest_api": "Rest API", + "rest_api": "REST API", "modbus_tcp": "Modbus TCP" } } @@ -88,16 +88,16 @@ "name": "Phase 3 current" }, "non_resettable_counter_1": { - "name": "Non Resettable counter 1" + "name": "Non-resettable counter 1" }, "non_resettable_counter_2": { - "name": "Non Resettable counter 2" + "name": "Non-resettable counter 2" }, "non_resettable_counter_3": { - "name": "Non Resettable counter 3" + "name": "Non-resettable counter 3" }, "non_resettable_counter_4": { - "name": "Non Resettable counter 4" + "name": "Non-resettable counter 4" }, "resettable_counter_1": { "name": "Resettable counter 1" diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index a6cd3fb151e..8bd7e5904b0 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -54,7 +54,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def calc_method(self) -> str: """Return the calculation method.""" - return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) # type: ignore[no-any-return] @property def lat_adj_method(self) -> str: @@ -68,12 +68,12 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def midnight_mode(self) -> str: """Return the midnight mode.""" - return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) + return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) # type: ignore[no-any-return] @property def school(self) -> str: """Return the school.""" - return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) + return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) # type: ignore[no-any-return] def get_new_prayer_times(self, for_date: date) -> dict[str, Any]: """Fetch prayer times for the specified date.""" diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json index afe085f5729..33e4219bbac 100644 --- a/homeassistant/components/israel_rail/manifest.json +++ b/homeassistant/components/israel_rail/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/israel_rail", "iot_class": "cloud_polling", "loggers": ["israelrailapi"], - "requirements": ["israel-rail-api==0.1.2"] + "requirements": ["israel-rail-api==0.1.3"] } diff --git a/homeassistant/components/ista_ecotrend/manifest.json b/homeassistant/components/ista_ecotrend/manifest.json index baa5fbde9c0..53638ac9a29 100644 --- a/homeassistant/components/ista_ecotrend/manifest.json +++ b/homeassistant/components/ista_ecotrend/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/ista_ecotrend", "iot_class": "cloud_polling", "loggers": ["pyecotrend_ista"], + "quality_scale": "gold", "requirements": ["pyecotrend-ista==3.3.1"] } diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml index a06aef7297f..ef665b04d41 100644 --- a/homeassistant/components/ista_ecotrend/quality_scale.yaml +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -50,14 +50,18 @@ rules: discovery: status: exempt comment: The integration is a web service, there are no discoverable devices. - docs-data-update: todo - docs-examples: todo + docs-data-update: done + docs-examples: + status: done + comment: describes how to use the integration with the statistics dashboard docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: + status: exempt + comment: changes are very rare (usually takes years) entity-category: status: done comment: The default category is appropriate. @@ -67,8 +71,12 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo - stale-devices: todo + repair-issues: + status: exempt + comment: integration has no repairs + stale-devices: + status: exempt + comment: integration has no stale devices # Platinum async-dependency: todo diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py index db64dbf85db..5d790a3cf1c 100644 --- a/homeassistant/components/ista_ecotrend/util.py +++ b/homeassistant/components/ista_ecotrend/util.py @@ -108,22 +108,22 @@ def get_statistics( if monthly_consumptions := get_consumptions(data, value_type): return [ { - "value": as_number( - get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get( - "additionalValue" - if value_type == IstaValueType.ENERGY - else "value" - ) - ), + "value": as_number(value), "date": consumptions["date"], } for consumptions in monthly_consumptions - if get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") + if ( + value := ( + consumption := get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ) + ).get( + "additionalValue" + if value_type == IstaValueType.ENERGY + and consumption.get("additionalValue") is not None + else "value" + ) + ) ] return None diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 5d4603cafc0..68ca63b6bb5 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -171,7 +171,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: _LOGGER.debug("ISY Starting Event Stream and automatic updates") isy.websocket.start() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_auto_update) ) @@ -179,11 +178,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: IsyConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: IsyConfigEntry, isy: ISY diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 2acebee8599..4f0217fd0c6 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -143,7 +143,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: IsyConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -316,7 +316,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for ISY/IoX.""" async def async_step_init( diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py index bf9cff238cd..41392c5cee1 100644 --- a/homeassistant/components/ituran/__init__.py +++ b/homeassistant/components/ituran/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR, ] diff --git a/homeassistant/components/ituran/binary_sensor.py b/homeassistant/components/ituran/binary_sensor.py new file mode 100644 index 00000000000..8a18cca8968 --- /dev/null +++ b/homeassistant/components/ituran/binary_sensor.py @@ -0,0 +1,75 @@ +"""Binary sensors for Ituran vehicles.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from propcache.api import cached_property +from pyituran import Vehicle + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IturanBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Ituran binary sensor entity.""" + + value_fn: Callable[[Vehicle], bool] + supported_fn: Callable[[Vehicle], bool] = lambda _: True + + +BINARY_SENSOR_TYPES: list[IturanBinarySensorEntityDescription] = [ + IturanBinarySensorEntityDescription( + key="is_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda vehicle: vehicle.is_charging, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Ituran binary sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanBinarySensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() + for description in BINARY_SENSOR_TYPES + if description.supported_fn(vehicle) + ) + + +class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity): + """Ituran binary sensor.""" + + entity_description: IturanBinarySensorEntityDescription + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + description: IturanBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, license_plate, description.key) + self.entity_description = description + + @cached_property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.vehicle) diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py index 5f816709864..0656bdfa497 100644 --- a/homeassistant/components/ituran/device_tracker.py +++ b/homeassistant/components/ituran/device_tracker.py @@ -2,6 +2,8 @@ from __future__ import annotations +from propcache.api import cached_property + from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,12 +40,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity): """Initialize the device tracker.""" super().__init__(coordinator, license_plate, "device_tracker") - @property + @cached_property def latitude(self) -> float | None: """Return latitude value of the device.""" return self.vehicle.gps_coordinates[0] - @property + @cached_property def longitude(self) -> float | None: """Return longitude value of the device.""" return self.vehicle.gps_coordinates[1] diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json index bd9182f1569..0b721ca5001 100644 --- a/homeassistant/components/ituran/icons.json +++ b/homeassistant/components/ituran/icons.json @@ -9,6 +9,9 @@ "address": { "default": "mdi:map-marker" }, + "battery_range": { + "default": "mdi:ev-station" + }, "battery_voltage": { "default": "mdi:car-battery" }, diff --git a/homeassistant/components/ituran/manifest.json b/homeassistant/components/ituran/manifest.json index 0cf20d3c6b2..d63ca2fef84 100644 --- a/homeassistant/components/ituran/manifest.json +++ b/homeassistant/components/ituran/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["pyituran==0.1.4"] + "requirements": ["pyituran==0.1.5"] } diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py index a115b2be89c..50e86b374a1 100644 --- a/homeassistant/components/ituran/sensor.py +++ b/homeassistant/components/ituran/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from propcache.api import cached_property from pyituran import Vehicle from homeassistant.components.sensor import ( @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( DEGREE, + PERCENTAGE, UnitOfElectricPotential, UnitOfLength, UnitOfSpeed, @@ -33,6 +35,7 @@ class IturanSensorEntityDescription(SensorEntityDescription): """Describes Ituran sensor entity.""" value_fn: Callable[[Vehicle], StateType | datetime] + supported_fn: Callable[[Vehicle], bool] = lambda _: True SENSOR_TYPES: list[IturanSensorEntityDescription] = [ @@ -42,6 +45,22 @@ SENSOR_TYPES: list[IturanSensorEntityDescription] = [ entity_registry_enabled_default=False, value_fn=lambda vehicle: vehicle.address, ), + IturanSensorEntityDescription( + key="battery_level", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda vehicle: vehicle.battery_level, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), + IturanSensorEntityDescription( + key="battery_range", + translation_key="battery_range", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=0, + value_fn=lambda vehicle: vehicle.battery_range, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), IturanSensorEntityDescription( key="battery_voltage", translation_key="battery_voltage", @@ -92,14 +111,15 @@ async def async_setup_entry( """Set up the Ituran sensors from config entry.""" coordinator = config_entry.runtime_data async_add_entities( - IturanSensor(coordinator, license_plate, description) + IturanSensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() for description in SENSOR_TYPES - for license_plate in coordinator.data + if description.supported_fn(vehicle) ) class IturanSensor(IturanBaseEntity, SensorEntity): - """Ituran device tracker.""" + """Ituran sensor.""" entity_description: IturanSensorEntityDescription @@ -113,7 +133,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity): super().__init__(coordinator, license_plate, description.key) self.entity_description = description - @property + @cached_property def native_value(self) -> StateType | datetime: """Return the state of the device.""" return self.entity_description.value_fn(self.vehicle) diff --git a/homeassistant/components/ituran/strings.json b/homeassistant/components/ituran/strings.json index efc60ef454b..ededb5232f5 100644 --- a/homeassistant/components/ituran/strings.json +++ b/homeassistant/components/ituran/strings.json @@ -40,6 +40,9 @@ "address": { "name": "Address" }, + "battery_range": { + "name": "Remaining range" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 9eee4bbb363..9dc84971a21 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from functools import partial from typing import Any from jellyfin_apiclient_python import JellyfinClient @@ -12,6 +13,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant @@ -156,6 +158,51 @@ def fetch_items( ] +async def search_items( + hass: HomeAssistant, client: JellyfinClient, user_id: str, query: SearchMediaQuery +) -> list[BrowseMedia]: + """Search items in Jellyfin server.""" + search_result: list[BrowseMedia] = [] + + items: list[dict[str, Any]] = [] + # Search for items based on media filter classes (or all if none specified) + media_types: list[MediaClass] | list[None] = [] + if query.media_filter_classes: + media_types = query.media_filter_classes + else: + media_types = [None] + + for media_type in media_types: + items_dict: dict[str, Any] = await hass.async_add_executor_job( + partial( + client.jellyfin.search_media_items, + term=query.search_query, + media=media_type, + parent_id=query.media_content_id, + ) + ) + items.extend(items_dict.get("Items", [])) + + for item in items: + content_type: str = item["MediaType"] + + response = BrowseMedia( + media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get( + content_type, MediaClass.DIRECTORY + ), + media_content_id=item["Id"], + media_content_type=content_type, + title=item["Name"], + thumbnail=get_artwork_url(client, item), + can_play=bool(content_type in PLAYABLE_MEDIA_TYPES), + can_expand=item.get("IsFolder", False), + children=None, + ) + search_result.append(response) + + return search_result + + async def get_media_info( hass: HomeAssistant, client: JellyfinClient, diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index 91fe0885e4c..4855231184e 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -66,8 +66,7 @@ def _connect_to_address( ) -> dict[str, Any]: """Connect to the Jellyfin server.""" result: dict[str, Any] = connection_manager.connect_to_address(url) - - if result["State"] != CONNECTION_STATE["ServerSignIn"]: + if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn: raise CannotConnect return result diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index cd22ad4ab39..30149453ba3 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An self.api_client.jellyfin.sessions ) + if sessions is None: + return {} + sessions_by_id: dict[str, dict[str, Any]] = { session["Id"]: session for session in sessions diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index d6b2261acaa..839d9e685fc 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"], - "single_config_entry": true + "requirements": ["jellyfin-apiclient-python==1.11.0"] } diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index e0fcc8a559b..6f3c41d282f 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.media_player import ( @@ -10,17 +11,21 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, MediaType, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import parse_datetime -from .browse_media import build_item_response, build_root_response +from .browse_media import build_item_response, build_root_response, search_items from .client_wrapper import get_artwork_url from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinClientEntity +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -177,10 +182,15 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" commands: list[str] = self.capabilities.get("SupportedCommands", []) - controllable = self.capabilities.get("SupportsMediaControl", False) + _LOGGER.debug( + "Supported commands for device %s, client %s, %s", + self.device_name, + self.client_name, + commands, + ) features = MediaPlayerEntityFeature(0) - if controllable: + if "PlayMediaSource" in commands: features |= ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA @@ -188,6 +198,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.SEARCH_MEDIA ) if "Mute" in commands: @@ -266,3 +277,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): media_content_type, media_content_id, ) + + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + result = await search_items( + self.hass, self.coordinator.api_client, self.coordinator.user_id, query + ) + return SearchMedia(result=result) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index ec73d960140..0f5a066600c 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -29,7 +29,8 @@ from .const import ( DEFAULT_LANGUAGE, DOMAIN, ) -from .entity import JewishCalendarConfigEntry, JewishCalendarData +from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator +from .entity import JewishCalendarConfigEntry from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -69,7 +70,7 @@ async def async_setup_entry( ) ) - config_entry.runtime_data = JewishCalendarData( + data = JewishCalendarData( language, diaspora, location, @@ -77,15 +78,11 @@ async def async_setup_entry( havdalah_offset, ) + coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data) + await coordinator.async_config_entry_first_refresh() + + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - - async def update_listener( - hass: HomeAssistant, config_entry: JewishCalendarConfigEntry - ) -> None: - # Trigger update of states for all platforms - await hass.config_entries.async_reload(config_entry.entry_id) - - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True @@ -93,7 +90,13 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + coordinator = config_entry.runtime_data + if coordinator.event_unsub: + coordinator.event_unsub() + return unload_ok async def async_migrate_entry( diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 79b49050cc2..205691bc183 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -23,36 +22,29 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity PARALLEL_UPDATES = 0 -@dataclass(frozen=True) -class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): - """Binary Sensor description mixin class for Jewish Calendar.""" - - is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False - - -@dataclass(frozen=True) -class JewishCalendarBinarySensorEntityDescription( - JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class JewishCalendarBinarySensorEntityDescription(BinarySensorEntityDescription): """Binary Sensor Entity description for Jewish Calendar.""" + is_on: Callable[[Zmanim], Callable[[dt.datetime], bool]] + BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( JewishCalendarBinarySensorEntityDescription( key="issur_melacha_in_effect", translation_key="issur_melacha_in_effect", - is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), + is_on=lambda state: state.issur_melacha_in_effect, ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", translation_key="erev_shabbat_hag", - is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), + is_on=lambda state: state.erev_shabbat_chag, entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", translation_key="motzei_shabbat_hag", - is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), + is_on=lambda state: state.motzei_shabbat_chag, entity_registry_enabled_default=False, ), ) @@ -73,50 +65,19 @@ async def async_setup_entry( class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): """Representation of an Jewish Calendar binary sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _update_unsub: CALLBACK_TYPE | None = None entity_description: JewishCalendarBinarySensorEntityDescription @property def is_on(self) -> bool: """Return true if sensor is on.""" - zmanim = self.make_zmanim(dt.date.today()) - return self.entity_description.is_on(zmanim, dt_util.now()) + return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now()) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - self._schedule_update() - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._update_unsub: - self._update_unsub() - self._update_unsub = None - return await super().async_will_remove_from_hass() - - @callback - def _update(self, now: dt.datetime | None = None) -> None: - """Update the state of the sensor.""" - self._update_unsub = None - self._schedule_update() - self.async_write_ha_state() - - def _schedule_update(self) -> None: - """Schedule the next update of the sensor.""" - now = dt_util.now() - zmanim = self.make_zmanim(dt.date.today()) - update = zmanim.netz_hachama.local + dt.timedelta(days=1) - candle_lighting = zmanim.candle_lighting - if candle_lighting is not None and now < candle_lighting < update: - update = candle_lighting - havdalah = zmanim.havdalah - if havdalah is not None and now < havdalah < update: - update = havdalah - if self._update_unsub: - self._update_unsub() - self._update_unsub = event.async_track_point_in_time( - self.hass, self._update, update - ) + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + return [ + zmanim.netz_hachama.local + dt.timedelta(days=1), + zmanim.candle_lighting, + zmanim.havdalah, + ] diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index e896bc90c9e..f52e14537b3 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -9,7 +9,11 @@ import zoneinfo from hdate.translator import Language import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, @@ -124,7 +128,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class JewishCalendarOptionsFlowHandler(OptionsFlow): +class JewishCalendarOptionsFlowHandler(OptionsFlowWithReload): """Handle Jewish Calendar options.""" async def async_step_init( diff --git a/homeassistant/components/jewish_calendar/coordinator.py b/homeassistant/components/jewish_calendar/coordinator.py new file mode 100644 index 00000000000..21713313043 --- /dev/null +++ b/homeassistant/components/jewish_calendar/coordinator.py @@ -0,0 +1,116 @@ +"""Data update coordinator for Jewish calendar.""" + +from dataclasses import dataclass +import datetime as dt +import logging + +from hdate import HDateInfo, Location, Zmanim +from hdate.translator import Language, set_language + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import event +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator] + + +@dataclass +class JewishCalendarData: + """Jewish Calendar runtime dataclass.""" + + language: Language + diaspora: bool + location: Location + candle_lighting_offset: int + havdalah_offset: int + dateinfo: HDateInfo | None = None + zmanim: Zmanim | None = None + + +class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]): + """Data update coordinator class for Jewish calendar.""" + + config_entry: JewishCalendarConfigEntry + event_unsub: CALLBACK_TYPE | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: JewishCalendarConfigEntry, + data: JewishCalendarData, + ) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry) + self.data = data + self._unsub_update: CALLBACK_TYPE | None = None + set_language(data.language) + + async def _async_update_data(self) -> JewishCalendarData: + """Return HDate and Zmanim for today.""" + now = dt_util.now() + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + + today = now.date() + + self.data.dateinfo = HDateInfo(today, self.data.diaspora) + self.data.zmanim = self.make_zmanim(today) + self.async_schedule_future_update() + return self.data + + @callback + def async_schedule_future_update(self) -> None: + """Schedule the next update of the sensor for the upcoming midnight.""" + # Cancel any existing update + if self._unsub_update: + self._unsub_update() + self._unsub_update = None + + # Calculate the next midnight + next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) + + _LOGGER.debug("Scheduling next update at %s", next_midnight) + + # Schedule update at next midnight + self._unsub_update = event.async_track_point_in_time( + self.hass, self._handle_midnight_update, next_midnight + ) + + @callback + def _handle_midnight_update(self, _now: dt.datetime) -> None: + """Handle midnight update callback.""" + self._unsub_update = None + self.async_set_updated_data(self.data) + + async def async_shutdown(self) -> None: + """Cancel any scheduled updates when the coordinator is shutting down.""" + await super().async_shutdown() + if self._unsub_update: + self._unsub_update() + self._unsub_update = None + + def make_zmanim(self, date: dt.date) -> Zmanim: + """Create a Zmanim object.""" + return Zmanim( + date=date, + location=self.data.location, + candle_lighting_offset=self.data.candle_lighting_offset, + havdalah_offset=self.data.havdalah_offset, + ) + + @property + def zmanim(self) -> Zmanim: + """Return the current Zmanim.""" + assert self.data.zmanim is not None, "Zmanim data not available" + return self.data.zmanim + + @property + def dateinfo(self) -> HDateInfo: + """Return the current HDateInfo.""" + assert self.data.dateinfo is not None, "HDateInfo data not available" + return self.data.dateinfo diff --git a/homeassistant/components/jewish_calendar/diagnostics.py b/homeassistant/components/jewish_calendar/diagnostics.py index 27415282b6d..f2db0786b12 100644 --- a/homeassistant/components/jewish_calendar/diagnostics.py +++ b/homeassistant/components/jewish_calendar/diagnostics.py @@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics( return { "entry_data": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(asdict(entry.runtime_data), TO_REDACT), + "data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT), } diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index b92d30048f0..d3007212739 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,46 +1,27 @@ """Entity representing a Jewish Calendar sensor.""" -from dataclasses import dataclass +from abc import abstractmethod import datetime as dt -from hdate import HDateInfo, Location, Zmanim -from hdate.translator import Language, set_language +from hdate import Zmanim -from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN - -type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] +from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator -@dataclass -class JewishCalendarDataResults: - """Jewish Calendar results dataclass.""" - - daytime_date: HDateInfo - after_shkia_date: HDateInfo - after_tzais_date: HDateInfo - zmanim: Zmanim - - -@dataclass -class JewishCalendarData: - """Jewish Calendar runtime dataclass.""" - - language: Language - diaspora: bool - location: Location - candle_lighting_offset: int - havdalah_offset: int - results: JewishCalendarDataResults | None = None - - -class JewishCalendarEntity(Entity): +class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True + _attr_should_poll = False + _update_unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -48,20 +29,48 @@ class JewishCalendarEntity(Entity): description: EntityDescription, ) -> None: """Initialize a Jewish Calendar entity.""" + super().__init__(config_entry.runtime_data) self.entity_description = description self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, ) - self.data = config_entry.runtime_data - set_language(self.data.language) - def make_zmanim(self, date: dt.date) -> Zmanim: - """Create a Zmanim object.""" - return Zmanim( - date=date, - location=self.data.location, - candle_lighting_offset=self.data.candle_lighting_offset, - havdalah_offset=self.data.havdalah_offset, + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._schedule_update() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + + @abstractmethod + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + + def _schedule_update(self) -> None: + """Schedule the next update of the sensor.""" + now = dt_util.now() + update = dt_util.start_of_local_day() + dt.timedelta(days=1) + + for update_time in self._update_times(self.coordinator.zmanim): + if update_time is not None and now < update_time < update: + update = update_time + + if self._update_unsub: + self._update_unsub() + self._update_unsub = event.async_track_point_in_time( + self.hass, self._update, update ) + + @callback + def _update(self, now: dt.datetime | None = None) -> None: + """Update the sensor data.""" + self._update_unsub = None + self._schedule_update() + self.async_write_ha_state() diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 91c618e1c1c..579c8e0f6a6 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -16,17 +16,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.sun import get_astral_event_date -from homeassistant.util import dt as dt_util +import homeassistant.util.dt as dt_util -from .entity import ( - JewishCalendarConfigEntry, - JewishCalendarDataResults, - JewishCalendarEntity, -) +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -37,15 +32,19 @@ class JewishCalendarBaseSensorDescription(SensorEntityDescription): """Base class describing Jewish Calendar sensor entities.""" value_fn: Callable | None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None @dataclass(frozen=True, kw_only=True) class JewishCalendarSensorDescription(JewishCalendarBaseSensorDescription): """Class describing Jewish Calendar sensor entities.""" - value_fn: Callable[[JewishCalendarDataResults], str | int] - attr_fn: Callable[[JewishCalendarDataResults], dict[str, str]] | None = None + value_fn: Callable[[HDateInfo], str | int] + attr_fn: Callable[[HDateInfo], dict[str, str]] | None = None options_fn: Callable[[bool], list[str]] | None = None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None = ( + lambda zmanim: zmanim.shkia.local + ) @dataclass(frozen=True, kw_only=True) @@ -55,17 +54,18 @@ class JewishCalendarTimestampSensorDescription(JewishCalendarBaseSensorDescripti value_fn: ( Callable[[HDateInfo, Callable[[dt.date], Zmanim]], dt.datetime | None] | None ) = None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None = None INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( JewishCalendarSensorDescription( key="date", translation_key="hebrew_date", - value_fn=lambda results: str(results.after_shkia_date.hdate), - attr_fn=lambda results: { - "hebrew_year": str(results.after_shkia_date.hdate.year), - "hebrew_month_name": str(results.after_shkia_date.hdate.month), - "hebrew_day": str(results.after_shkia_date.hdate.day), + value_fn=lambda info: str(info.hdate), + attr_fn=lambda info: { + "hebrew_year": str(info.hdate.year), + "hebrew_month_name": str(info.hdate.month), + "hebrew_day": str(info.hdate.day), }, ), JewishCalendarSensorDescription( @@ -73,24 +73,19 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( translation_key="weekly_portion", device_class=SensorDeviceClass.ENUM, options_fn=lambda _: [str(p) for p in Parasha], - value_fn=lambda results: results.after_tzais_date.upcoming_shabbat.parasha, + value_fn=lambda info: info.upcoming_shabbat.parasha, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarSensorDescription( key="holiday", translation_key="holiday", device_class=SensorDeviceClass.ENUM, options_fn=lambda diaspora: HolidayDatabase(diaspora).get_all_names(), - value_fn=lambda results: ", ".join( - str(holiday) for holiday in results.after_shkia_date.holidays - ), - attr_fn=lambda results: { - "id": ", ".join( - holiday.name for holiday in results.after_shkia_date.holidays - ), + value_fn=lambda info: ", ".join(str(holiday) for holiday in info.holidays), + attr_fn=lambda info: { + "id": ", ".join(holiday.name for holiday in info.holidays), "type": ", ".join( - dict.fromkeys( - _holiday.type.name for _holiday in results.after_shkia_date.holidays - ) + dict.fromkeys(_holiday.type.name for _holiday in info.holidays) ), }, ), @@ -98,13 +93,13 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( key="omer_count", translation_key="omer_count", entity_registry_enabled_default=False, - value_fn=lambda results: results.after_shkia_date.omer.total_days, + value_fn=lambda info: info.omer.total_days, ), JewishCalendarSensorDescription( key="daf_yomi", translation_key="daf_yomi", entity_registry_enabled_default=False, - value_fn=lambda results: results.daytime_date.daf_yomi, + value_fn=lambda info: info.daf_yomi, ), ) @@ -184,12 +179,14 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat.previous_day.gdate ).candle_lighting, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_shabbat_havdalah", translation_key="upcoming_shabbat_havdalah", entity_registry_enabled_default=False, value_fn=lambda at_date, mz: mz(at_date.upcoming_shabbat.gdate).havdalah, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_candle_lighting", @@ -197,6 +194,7 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate ).candle_lighting, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_havdalah", @@ -204,6 +202,7 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat_or_yom_tov.last_day.gdate ).havdalah, + next_update_fn=lambda zmanim: zmanim.havdalah, ), ) @@ -213,7 +212,7 @@ async def async_setup_entry( config_entry: JewishCalendarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Jewish calendar sensors .""" + """Set up the Jewish calendar sensors.""" sensors: list[JewishCalendarBaseSensor] = [ JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS ] @@ -229,44 +228,26 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC - async def async_update(self) -> None: - """Update the state of the sensor.""" + entity_description: JewishCalendarBaseSensorDescription + + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + if self.entity_description.next_update_fn is None: + return [] + return [self.entity_description.next_update_fn(zmanim)] + + def get_dateinfo(self) -> HDateInfo: + """Get the next date info.""" now = dt_util.now() - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + update = None - today = now.date() - event_date = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, today) + if self.entity_description.next_update_fn: + update = self.entity_description.next_update_fn(self.coordinator.zmanim) - if event_date is None: - _LOGGER.error("Can't get sunset event date for %s", today) - return - - sunset = dt_util.as_local(event_date) - - _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - - daytime_date = HDateInfo(today, diaspora=self.data.diaspora) - - # The Jewish day starts after darkness (called "tzais") and finishes at - # sunset ("shkia"). The time in between is a gray area - # (aka "Bein Hashmashot" # codespell:ignore - # - literally: "in between the sun and the moon"). - - # For some sensors, it is more interesting to consider the date to be - # tomorrow based on sunset ("shkia"), for others based on "tzais". - # Hence the following variables. - after_tzais_date = after_shkia_date = daytime_date - today_times = self.make_zmanim(today) - - if now > sunset: - after_shkia_date = daytime_date.next_day - - if today_times.havdalah and now > today_times.havdalah: - after_tzais_date = daytime_date.next_day - - self.data.results = JewishCalendarDataResults( - daytime_date, after_shkia_date, after_tzais_date, today_times - ) + _LOGGER.debug("Today: %s, update: %s", now.date(), update) + if update is not None and now >= update: + return self.coordinator.dateinfo.next_day + return self.coordinator.dateinfo class JewishCalendarSensor(JewishCalendarBaseSensor): @@ -283,23 +264,21 @@ class JewishCalendarSensor(JewishCalendarBaseSensor): super().__init__(config_entry, description) # Set the options for enumeration sensors if self.entity_description.options_fn is not None: - self._attr_options = self.entity_description.options_fn(self.data.diaspora) + self._attr_options = self.entity_description.options_fn( + self.coordinator.data.diaspora + ) @property def native_value(self) -> str | int | dt.datetime | None: """Return the state of the sensor.""" - if self.data.results is None: - return None - return self.entity_description.value_fn(self.data.results) + return self.entity_description.value_fn(self.get_dateinfo()) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - if self.data.results is None: + if self.entity_description.attr_fn is None: return {} - if self.entity_description.attr_fn is not None: - return self.entity_description.attr_fn(self.data.results) - return {} + return self.entity_description.attr_fn(self.get_dateinfo()) class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @@ -311,10 +290,8 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @property def native_value(self) -> dt.datetime | None: """Return the state of the sensor.""" - if self.data.results is None: - return None if self.entity_description.value_fn is None: - return self.data.results.zmanim.zmanim[self.entity_description.key].local + return self.coordinator.zmanim.zmanim[self.entity_description.key].local return self.entity_description.value_fn( - self.data.results.after_tzais_date, self.make_zmanim + self.get_dateinfo(), self.coordinator.make_zmanim ) diff --git a/homeassistant/components/jewish_calendar/services.py b/homeassistant/components/jewish_calendar/services.py index 6fdebe6f74d..f77f9be4e64 100644 --- a/homeassistant/components/jewish_calendar/services.py +++ b/homeassistant/components/jewish_calendar/services.py @@ -50,7 +50,6 @@ def async_setup_services(hass: HomeAssistant) -> None: today = now.date() event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) if event_date is None: - _LOGGER.error("Can't get sunset event date for %s", today) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="sunset_event" ) diff --git a/homeassistant/components/justnimbus/entity.py b/homeassistant/components/justnimbus/entity.py index f85c3f33f93..1d0e6a4c1bc 100644 --- a/homeassistant/components/justnimbus/entity.py +++ b/homeassistant/components/justnimbus/entity.py @@ -28,6 +28,7 @@ class JustNimbusEntity( identifiers={(DOMAIN, device_id)}, name="JustNimbus Sensor", manufacturer="JustNimbus", + sw_version=coordinator.data.api_version, ) @property diff --git a/homeassistant/components/keba/strings.json b/homeassistant/components/keba/strings.json index 49ce01f4332..1616df6237b 100644 --- a/homeassistant/components/keba/strings.json +++ b/homeassistant/components/keba/strings.json @@ -28,7 +28,7 @@ "fields": { "current": { "name": "Current", - "description": "The maximum current used for the charging process. The value is depending on the DIP-switch settings and the used cable of the charging station." + "description": "The maximum current used for the charging process. The value depends on the DIP switch settings and the cable used by the charging station." } } }, diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 7986158ab50..358f9600845 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -33,8 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> router = KeeneticRouter(hass, entry) await router.async_setup() - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -87,11 +85,6 @@ async def async_unload_entry( return unload_ok -async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): """Populate default options.""" host: str = entry.data[CONF_HOST] diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 3862d34398f..cec4796176e 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -8,7 +8,12 @@ from urllib.parse import urlparse from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -45,7 +50,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str | bytes | None = None + _host: str | bytes | None = None @staticmethod @callback @@ -61,8 +66,9 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - host = self.host or user_input[CONF_HOST] - self._async_abort_entries_match({CONF_HOST: host}) + host = self._host or user_input[CONF_HOST] + if self.source != SOURCE_RECONFIGURE: + self._async_abort_entries_match({CONF_HOST: host}) _client = Client( TelnetConnection( @@ -81,12 +87,17 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): except ConnectionException: errors["base"] = "cannot_connect" else: + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={CONF_HOST: host, **user_input}, + ) return self.async_create_entry( title=router_info.name, data={CONF_HOST: host, **user_input} ) host_schema: VolDictType = ( - {vol.Required(CONF_HOST): str} if not self.host else {} + {vol.Required(CONF_HOST): str} if not self._host else {} ) return self.async_show_form( @@ -102,6 +113,15 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + existing_entry_data = dict(self._get_reconfigure_entry().data) + self._host = existing_entry_data[CONF_HOST] + + return await self.async_step_user(user_input) + async def async_step_ssdp( self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: @@ -124,7 +144,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: host}) - self.host = host + self._host = host self.context["title_placeholders"] = { "name": friendly_name, "host": host, @@ -133,7 +153,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() -class KeeneticOptionsFlowHandler(OptionsFlow): +class KeeneticOptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" config_entry: KeeneticConfigEntry diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 93b59be122d..3098996d48f 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -21,7 +21,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "no_udn": "SSDP discovery info has no UDN", - "not_keenetic_ndms2": "Discovered device is not a Keenetic router" + "not_keenetic_ndms2": "Discovered device is not a Keenetic router", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 5abdfe5b4a7..7b1e133bb6e 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -13,8 +13,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", - "integration_type": "hub", + "integration_type": "device", "iot_class": "assumed_state", - "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.17"] + "loggers": ["keymitt_ble", "microbot"], + "requirements": ["PyMicroBot==0.0.23"] } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 2f876ca855d..8b81cd49279 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -101,19 +101,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Start a reauth flow entry.async_start_reauth(hass) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Notify backup listeners hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle update.""" - 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 diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index aa722d27944..27a10738f48 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.core import callback @@ -65,7 +65,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( @@ -99,7 +99,7 @@ class OptionsFlowHandler(OptionsFlow): ), } ) - self.add_suggested_values_to_schema( + data_schema = self.add_suggested_values_to_schema( data_schema, {"section_1": {"int": self.config_entry.options.get(CONF_INT, 10)}}, ) @@ -146,7 +146,7 @@ class SubentryFlowHandler(ConfigSubentryFlow): """Reconfigure a sensor.""" if user_input is not None: title = user_input.pop("name") - return self.async_update_and_abort( + return self.async_update_reload_and_abort( self._get_entry(), self._get_reconfigure_subentry(), data=user_input, diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8ad16642e45..ead846735c9 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -1,34 +1,17 @@ -"""Support KNX devices.""" +"""The KNX integration.""" from __future__ import annotations import contextlib -import logging from pathlib import Path from typing import Final import voluptuous as vol -from xknx import XKNX -from xknx.core import XknxConnectionState -from xknx.core.state_updater import StateTrackerType, TrackerOptions -from xknx.core.telegram_queue import TelegramQueue -from xknx.dpt import DPTBase -from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException -from xknx.io import ConnectionConfig, ConnectionType, SecureConfig -from xknx.telegram import AddressFilter, Telegram -from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress -from xknx.telegram.apci import GroupValueResponse, GroupValueWrite +from xknx.exceptions import XKNXException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_PORT, - CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import Event, HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config @@ -36,40 +19,17 @@ from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_KNX_CONNECTION_TYPE, CONF_KNX_EXPOSE, - CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_KNXKEY_FILENAME, - CONF_KNX_KNXKEY_PASSWORD, - CONF_KNX_LOCAL_IP, - CONF_KNX_MCAST_GRP, - CONF_KNX_MCAST_PORT, - CONF_KNX_RATE_LIMIT, - CONF_KNX_ROUTE_BACK, - CONF_KNX_ROUTING, - CONF_KNX_ROUTING_BACKBONE_KEY, - CONF_KNX_ROUTING_SECURE, - CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE, - CONF_KNX_SECURE_DEVICE_AUTHENTICATION, - CONF_KNX_SECURE_USER_ID, - CONF_KNX_SECURE_USER_PASSWORD, - CONF_KNX_STATE_UPDATER, - CONF_KNX_TELEGRAM_LOG_SIZE, - CONF_KNX_TUNNEL_ENDPOINT_IA, - CONF_KNX_TUNNELING, - CONF_KNX_TUNNELING_TCP, - CONF_KNX_TUNNELING_TCP_SECURE, DATA_HASS_CONFIG, DOMAIN, - KNX_ADDRESS, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI, SUPPORTED_PLATFORMS_YAML, - TELEGRAM_LOG_DEFAULT, ) -from .device import KNXInterfaceDevice -from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure -from .project import STORAGE_KEY as PROJECT_STORAGE_KEY, KNXProject +from .expose import create_knx_exposure +from .knx_module import KNXModule +from .project import STORAGE_KEY as PROJECT_STORAGE_KEY from .schema import ( BinarySensorSchema, ButtonSchema, @@ -91,13 +51,11 @@ from .schema import ( TimeSchema, WeatherSchema, ) -from .services import register_knx_services -from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore -from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams +from .services import async_setup_services +from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY +from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY from .websocket import register_panel -_LOGGER = logging.getLogger(__name__) - _KNX_YAML_CONFIG: Final = "knx_yaml_config" CONFIG_SCHEMA = vol.Schema( @@ -138,7 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := config.get(DOMAIN)) is not None: hass.data[_KNX_YAML_CONFIG] = dict(conf) - register_knx_services(hass) + async_setup_services(hass) return True @@ -214,11 +172,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update a given config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove a config entry.""" @@ -255,243 +208,3 @@ async def async_remove_config_entry_device( if entity.device_id == device_entry.id: await knx_module.config_store.delete_entity(entity.entity_id) return True - - -class KNXModule: - """Representation of KNX Object.""" - - def __init__( - self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry - ) -> None: - """Initialize KNX module.""" - self.hass = hass - self.config_yaml = config - self.connected = False - self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] - self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} - self.entry = entry - - self.project = KNXProject(hass=hass, entry=entry) - self.config_store = KNXConfigStore(hass=hass, config_entry=entry) - - default_state_updater = ( - TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) - if self.entry.data[CONF_KNX_STATE_UPDATER] - else TrackerOptions( - tracker_type=StateTrackerType.INIT, update_interval_min=60 - ) - ) - self.xknx = XKNX( - address_format=self.project.get_address_format(), - connection_config=self.connection_config(), - rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=default_state_updater, - ) - self.xknx.connection_manager.register_connection_state_changed_cb( - self.connection_state_changed_cb - ) - self.telegrams = Telegrams( - hass=hass, - xknx=self.xknx, - project=self.project, - log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), - ) - self.interface_device = KNXInterfaceDevice( - hass=hass, entry=entry, xknx=self.xknx - ) - - self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} - self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} - self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() - - self.entry.async_on_unload( - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - ) - self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) - - async def start(self) -> None: - """Start XKNX object. Connect to tunneling or Routing device.""" - await self.project.load_project(self.xknx) - await self.config_store.load_data() - await self.telegrams.load_history() - await self.xknx.start() - - async def stop(self, event: Event | None = None) -> None: - """Stop XKNX object. Disconnect from tunneling or Routing device.""" - await self.xknx.stop() - await self.telegrams.save_history() - - def connection_config(self) -> ConnectionConfig: - """Return the connection_config.""" - _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] - _knxkeys_file: str | None = ( - self.hass.config.path( - STORAGE_DIR, - self.entry.data[CONF_KNX_KNXKEY_FILENAME], - ) - if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None - else None - ) - if _conn_type == CONF_KNX_ROUTING: - return ConnectionConfig( - connection_type=ConnectionType.ROUTING, - individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], - multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], - multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING, - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING_TCP: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING_TCP, - individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING_TCP_SECURE, - individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - secure_config=SecureConfig( - user_id=self.entry.data.get(CONF_KNX_SECURE_USER_ID), - user_password=self.entry.data.get(CONF_KNX_SECURE_USER_PASSWORD), - device_authentication_password=self.entry.data.get( - CONF_KNX_SECURE_DEVICE_AUTHENTICATION - ), - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - auto_reconnect=True, - threaded=True, - ) - if _conn_type == CONF_KNX_ROUTING_SECURE: - return ConnectionConfig( - connection_type=ConnectionType.ROUTING_SECURE, - individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], - multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], - multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - secure_config=SecureConfig( - backbone_key=self.entry.data.get(CONF_KNX_ROUTING_BACKBONE_KEY), - latency_ms=self.entry.data.get( - CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE - ), - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - auto_reconnect=True, - threaded=True, - ) - return ConnectionConfig( - auto_reconnect=True, - individual_address=self.entry.data.get( - CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload - ), - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - - def connection_state_changed_cb(self, state: XknxConnectionState) -> None: - """Call invoked after a KNX connection state change was received.""" - self.connected = state == XknxConnectionState.CONNECTED - for device in self.xknx.devices: - device.after_update() - - def telegram_received_cb(self, telegram: Telegram) -> None: - """Call invoked after a KNX telegram was received.""" - # Not all telegrams have serializable data. - data: int | tuple[int, ...] | None = None - value = None - if ( - isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) - and telegram.payload.value is not None - and isinstance( - telegram.destination_address, (GroupAddress, InternalGroupAddress) - ) - ): - data = telegram.payload.value.value - if transcoder := ( - self.group_address_transcoder.get(telegram.destination_address) - or next( - ( - _transcoder - for _filter, _transcoder in self._address_filter_transcoder.items() - if _filter.match(telegram.destination_address) - ), - None, - ) - ): - try: - value = transcoder.from_knx(telegram.payload.value) - except (ConversionError, CouldNotParseTelegram) as err: - _LOGGER.warning( - ( - "Error in `knx_event` at decoding type '%s' from" - " telegram %s\n%s" - ), - transcoder.__name__, - telegram, - err, - ) - - self.hass.bus.async_fire( - "knx_event", - { - "data": data, - "destination": str(telegram.destination_address), - "direction": telegram.direction.value, - "value": value, - "source": str(telegram.source_address), - "telegramtype": telegram.payload.__class__.__name__, - }, - ) - - def register_event_callback(self) -> TelegramQueue.Callback: - """Register callback for knx_event within XKNX TelegramQueue.""" - address_filters = [] - for filter_set in self.config_yaml[CONF_EVENT]: - _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) - address_filters.extend(_filters) - if (dpt := filter_set.get(CONF_TYPE)) and ( - transcoder := DPTBase.parse_transcoder(dpt) - ): - self._address_filter_transcoder.update( - dict.fromkeys(_filters, transcoder) - ) - - return self.xknx.telegram_queue.register_telegram_received_cb( - self.telegram_received_cb, - address_filters=address_filters, - group_addresses=[], - match_for_outgoing=True, - ) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index c11612f79bf..947d382a12c 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP binary sensors.""" +"""Support for KNX binary sensor entities.""" from __future__ import annotations @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( ATTR_COUNTER, ATTR_SOURCE, @@ -39,7 +38,9 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity -from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE +from .knx_module import KNXModule +from .storage.const import CONF_ENTITY, CONF_GA_SENSOR +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -146,17 +147,17 @@ class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity): unique_id=unique_id, entity_config=config[CONF_ENTITY], ) + knx_conf = ConfigExtractor(config[DOMAIN]) self._device = XknxBinarySensor( xknx=knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address_state=[ - config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE], - *config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE], - ], - sync_state=config[DOMAIN][CONF_SYNC_STATE], - invert=config[DOMAIN].get(CONF_INVERT, False), - ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False), - context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT), - reset_after=config[DOMAIN].get(CONF_RESET_AFTER), + group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR), + sync_state=knx_conf.get(CONF_SYNC_STATE), + invert=knx_conf.get(CONF_INVERT, default=False), + ignore_internal_state=knx_conf.get( + CONF_IGNORE_INTERNAL_STATE, default=False + ), + context_timeout=knx_conf.get(CONF_CONTEXT_TIMEOUT), + reset_after=knx_conf.get(CONF_RESET_AFTER), ) self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 538299a0556..2c2baa3a218 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP buttons.""" +"""Support for KNX button entities.""" from __future__ import annotations @@ -11,9 +11,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index fdce5e0c470..f59d48de629 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP climate devices.""" +"""Support for KNX climate entities.""" from __future__ import annotations @@ -37,9 +37,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import ClimateSchema ATTR_COMMAND_VALUE = "command_value" diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 14a9016bcb9..7772f366493 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from typing import Any, Final, Literal @@ -20,11 +19,11 @@ from xknx.io.util import validate_ip as xknx_validate_ip from xknx.secure.keyring import Keyring, XMLInterface from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, - ConfigEntryBaseFlow, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -103,12 +102,14 @@ _PORT_SELECTOR = vol.All( ) -class KNXCommonFlow(ABC, ConfigEntryBaseFlow): - """Base class for KNX flows.""" +class KNXConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a KNX config flow.""" - def __init__(self, initial_data: KNXConfigEntryData) -> None: - """Initialize KNXCommonFlow.""" - self.initial_data = initial_data + VERSION = 1 + + def __init__(self) -> None: + """Initialize KNX config flow.""" + self.initial_data = DEFAULT_ENTRY_DATA self.new_entry_data = KNXConfigEntryData() self.new_title: str | None = None @@ -121,19 +122,21 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self._gatewayscanner: GatewayScanner | None = None self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: + """Get the options flow for this handler.""" + return KNXOptionsFlow(config_entry) + @property def _xknx(self) -> XKNX: """Return XKNX instance.""" - if isinstance(self, OptionsFlow) and ( + if (self.source == SOURCE_RECONFIGURE) and ( knx_module := self.hass.data.get(KNX_MODULE_KEY) ): return knx_module.xknx return XKNX() - @abstractmethod - def finish_flow(self) -> ConfigFlowResult: - """Finish the flow.""" - @property def connection_type(self) -> str: """Return the configured connection type.""" @@ -150,6 +153,61 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), ) + @callback + def finish_flow(self) -> ConfigFlowResult: + """Create or update the ConfigEntry.""" + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + _tunnel_endpoint_str = self.initial_data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, "Tunneling" + ) + if self.new_title and not entry.title.startswith( + # Overwrite standard titles, but not user defined ones + ( + f"KNX {self.initial_data[CONF_KNX_CONNECTION_TYPE]}", + CONF_KNX_AUTOMATIC.capitalize(), + "Tunneling @ ", + f"{_tunnel_endpoint_str} @", + "Tunneling UDP @ ", + "Tunneling TCP @ ", + "Secure Tunneling", + "Routing as ", + "Secure Routing as ", + ) + ): + self.new_title = None + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self.new_entry_data, + title=self.new_title or UNDEFINED, + ) + + title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" + return self.async_create_entry( + title=title, + data=DEFAULT_ENTRY_DATA | self.new_entry_data, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_connection_type() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of existing entry.""" + entry = self._get_reconfigure_entry() + self.initial_data = dict(entry.data) # type: ignore[assignment] + return self.async_show_menu( + step_id="reconfigure", + menu_options=[ + "connection_type", + "secure_knxkeys", + ], + ) + async def async_step_connection_type( self, user_input: dict | None = None ) -> ConfigFlowResult: @@ -441,7 +499,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) ip_address: str | None if ( # initial attempt on ConfigFlow or coming from automatic / routing - (isinstance(self, ConfigFlow) or not _reconfiguring_existing_tunnel) + not _reconfiguring_existing_tunnel and not user_input and self._selected_tunnel is not None ): # default to first found tunnel @@ -841,52 +899,20 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) -class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): - """Handle a KNX config flow.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize KNX options flow.""" - super().__init__(initial_data=DEFAULT_ENTRY_DATA) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: - """Get the options flow for this handler.""" - return KNXOptionsFlow(config_entry) - - @callback - def finish_flow(self) -> ConfigFlowResult: - """Create the ConfigEntry.""" - title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" - return self.async_create_entry( - title=title, - data=DEFAULT_ENTRY_DATA | self.new_entry_data, - ) - - async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - return await self.async_step_connection_type() - - -class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): +class KNXOptionsFlow(OptionsFlowWithReload): """Handle KNX options.""" - general_settings: dict - def __init__(self, config_entry: ConfigEntry) -> None: """Initialize KNX options flow.""" - super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] + self.initial_data = dict(config_entry.data) @callback - def finish_flow(self) -> ConfigFlowResult: + def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult: """Update the ConfigEntry and finish the flow.""" - new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data + new_data = self.initial_data | new_entry_data self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, - title=self.new_title or UNDEFINED, ) return self.async_create_entry(title="", data={}) @@ -894,26 +920,20 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX options.""" - return self.async_show_menu( - step_id="init", - menu_options=[ - "connection_type", - "communication_settings", - "secure_knxkeys", - ], - ) + return await self.async_step_communication_settings() async def async_step_communication_settings( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX communication settings.""" if user_input is not None: - self.new_entry_data = KNXConfigEntryData( - state_updater=user_input[CONF_KNX_STATE_UPDATER], - rate_limit=user_input[CONF_KNX_RATE_LIMIT], - telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + return self.finish_flow( + KNXConfigEntryData( + state_updater=user_input[CONF_KNX_STATE_UPDATER], + rate_limit=user_input[CONF_KNX_RATE_LIMIT], + telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + ) ) - return self.finish_flow() data_schema = { vol.Required( diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3ce79b4ca7a..dbc02f08245 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule DOMAIN: Final = "knx" KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN) diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 3068e5d7ef1..ef7084661f1 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -1,8 +1,8 @@ -"""Support for KNX/IP covers.""" +"""Support for KNX cover entities.""" from __future__ import annotations -from typing import Any, Literal +from typing import Any from xknx import XKNX from xknx.devices import Cover as XknxCover @@ -28,22 +28,20 @@ from homeassistant.helpers.entity_platform import ( ) from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY, CoverConf from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import CoverSchema from .storage.const import ( CONF_ENTITY, CONF_GA_ANGLE, - CONF_GA_PASSIVE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, - CONF_GA_STATE, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_UP_DOWN, - CONF_GA_WRITE, ) +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -230,38 +228,24 @@ class KnxYamlCover(_KnxCover, KnxYamlEntity): def _create_ui_cover(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxCover: """Return a KNX Light device to be used within XKNX.""" - def get_address( - key: str, address_type: Literal["write", "state"] = CONF_GA_WRITE - ) -> str | None: - """Get a single group address for given key.""" - return knx_config[key][address_type] if key in knx_config else None - - def get_addresses( - key: str, address_type: Literal["write", "state"] = CONF_GA_STATE - ) -> list[Any] | None: - """Get group address including passive addresses as list.""" - return ( - [knx_config[key][address_type], *knx_config[key][CONF_GA_PASSIVE]] - if key in knx_config - else None - ) + conf = ConfigExtractor(knx_config) return XknxCover( xknx=xknx, name=name, - group_address_long=get_addresses(CONF_GA_UP_DOWN, CONF_GA_WRITE), - group_address_short=get_addresses(CONF_GA_STEP, CONF_GA_WRITE), - group_address_stop=get_addresses(CONF_GA_STOP, CONF_GA_WRITE), - group_address_position=get_addresses(CONF_GA_POSITION_SET, CONF_GA_WRITE), - group_address_position_state=get_addresses(CONF_GA_POSITION_STATE), - group_address_angle=get_address(CONF_GA_ANGLE), - group_address_angle_state=get_addresses(CONF_GA_ANGLE), - travel_time_down=knx_config[CoverConf.TRAVELLING_TIME_DOWN], - travel_time_up=knx_config[CoverConf.TRAVELLING_TIME_UP], - invert_updown=knx_config.get(CoverConf.INVERT_UPDOWN, False), - invert_position=knx_config.get(CoverConf.INVERT_POSITION, False), - invert_angle=knx_config.get(CoverConf.INVERT_ANGLE, False), - sync_state=knx_config[CONF_SYNC_STATE], + group_address_long=conf.get_write_and_passive(CONF_GA_UP_DOWN), + group_address_short=conf.get_write_and_passive(CONF_GA_STEP), + group_address_stop=conf.get_write_and_passive(CONF_GA_STOP), + group_address_position=conf.get_write_and_passive(CONF_GA_POSITION_SET), + group_address_position_state=conf.get_state_and_passive(CONF_GA_POSITION_STATE), + group_address_angle=conf.get_write(CONF_GA_ANGLE), + group_address_angle_state=conf.get_state_and_passive(CONF_GA_ANGLE), + travel_time_down=conf.get(CoverConf.TRAVELLING_TIME_DOWN), + travel_time_up=conf.get(CoverConf.TRAVELLING_TIME_UP), + invert_updown=conf.get(CoverConf.INVERT_UPDOWN, default=False), + invert_position=conf.get(CoverConf.INVERT_POSITION, default=False), + invert_angle=conf.get(CoverConf.INVERT_ANGLE, default=False), + sync_state=conf.get(CONF_SYNC_STATE), ) diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index 7980e6a2bc3..a4fc8d276bc 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP date.""" +"""Support for KNX date entities.""" from __future__ import annotations @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -31,6 +30,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 7701597a8ef..04d04527241 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP datetime.""" +"""Support for KNX datetime entities.""" from __future__ import annotations @@ -23,7 +23,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -32,6 +31,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py index b43b5926d86..44fa7163360 100644 --- a/homeassistant/components/knx/device.py +++ b/homeassistant/components/knx/device.py @@ -1,4 +1,4 @@ -"""Handle KNX Devices.""" +"""Handle Home Assistant Devices for the KNX integration.""" from __future__ import annotations diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 2eb1f86e7fc..e4a48c9c68d 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -1,4 +1,4 @@ -"""Provides device triggers for KNX.""" +"""Provide device triggers for KNX.""" from __future__ import annotations diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 974a6b3b448..6d523dda0f5 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for KNX.""" +"""Diagnostics support for the KNX integration.""" from __future__ import annotations diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index a042c2b4c6b..c4379bcf869 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -1,4 +1,4 @@ -"""Base class for KNX devices.""" +"""Base classes for KNX entities.""" from __future__ import annotations @@ -17,7 +17,7 @@ from .storage.config_store import PlatformControllerBase from .storage.const import CONF_DEVICE_INFO if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule class KnxUiEntityPlatformController(PlatformControllerBase): diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 461e6f25879..0a42b6018ba 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -1,4 +1,4 @@ -"""Exposures to KNX bus.""" +"""Expose Home Assistant entity states to KNX.""" from __future__ import annotations diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 926b6458706..23f25dc8469 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP fans.""" +"""Support for KNX fan entities.""" from __future__ import annotations @@ -19,9 +19,9 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import FanSchema DEFAULT_PERCENTAGE: Final = 50 diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py new file mode 100644 index 00000000000..8974cad1baa --- /dev/null +++ b/homeassistant/components/knx/knx_module.py @@ -0,0 +1,301 @@ +"""Base module for the KNX integration.""" + +from __future__ import annotations + +import logging + +from xknx import XKNX +from xknx.core import XknxConnectionState +from xknx.core.state_updater import StateTrackerType, TrackerOptions +from xknx.core.telegram_queue import TelegramQueue +from xknx.dpt import DPTBase +from xknx.exceptions import ConversionError, CouldNotParseTelegram +from xknx.io import ConnectionConfig, ConnectionType, SecureConfig +from xknx.telegram import AddressFilter, Telegram +from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_EVENT, + CONF_HOST, + CONF_PORT, + CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_KNXKEY_FILENAME, + CONF_KNX_KNXKEY_PASSWORD, + CONF_KNX_LOCAL_IP, + CONF_KNX_MCAST_GRP, + CONF_KNX_MCAST_PORT, + CONF_KNX_RATE_LIMIT, + CONF_KNX_ROUTE_BACK, + CONF_KNX_ROUTING, + CONF_KNX_ROUTING_BACKBONE_KEY, + CONF_KNX_ROUTING_SECURE, + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION, + CONF_KNX_SECURE_USER_ID, + CONF_KNX_SECURE_USER_PASSWORD, + CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, + CONF_KNX_TUNNEL_ENDPOINT_IA, + CONF_KNX_TUNNELING, + CONF_KNX_TUNNELING_TCP, + CONF_KNX_TUNNELING_TCP_SECURE, + KNX_ADDRESS, + TELEGRAM_LOG_DEFAULT, +) +from .device import KNXInterfaceDevice +from .expose import KNXExposeSensor, KNXExposeTime +from .project import KNXProject +from .storage.config_store import KNXConfigStore +from .telegrams import Telegrams + +_LOGGER = logging.getLogger(__name__) + + +class KNXModule: + """Representation of KNX Object.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry + ) -> None: + """Initialize KNX module.""" + self.hass = hass + self.config_yaml = config + self.connected = False + self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] + self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} + self.entry = entry + + self.project = KNXProject(hass=hass, entry=entry) + self.config_store = KNXConfigStore(hass=hass, config_entry=entry) + + default_state_updater = ( + TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) + if self.entry.data[CONF_KNX_STATE_UPDATER] + else TrackerOptions( + tracker_type=StateTrackerType.INIT, update_interval_min=60 + ) + ) + self.xknx = XKNX( + address_format=self.project.get_address_format(), + connection_config=self.connection_config(), + rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], + state_updater=default_state_updater, + ) + self.xknx.connection_manager.register_connection_state_changed_cb( + self.connection_state_changed_cb + ) + self.telegrams = Telegrams( + hass=hass, + xknx=self.xknx, + project=self.project, + log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), + ) + self.interface_device = KNXInterfaceDevice( + hass=hass, entry=entry, xknx=self.xknx + ) + + self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} + self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() + + self.entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + ) + + async def start(self) -> None: + """Start XKNX object. Connect to tunneling or Routing device.""" + await self.project.load_project(self.xknx) + await self.config_store.load_data() + await self.telegrams.load_history() + await self.xknx.start() + + async def stop(self, event: Event | None = None) -> None: + """Stop XKNX object. Disconnect from tunneling or Routing device.""" + await self.xknx.stop() + await self.telegrams.save_history() + + def connection_config(self) -> ConnectionConfig: + """Return the connection_config.""" + _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] + _knxkeys_file: str | None = ( + self.hass.config.path( + STORAGE_DIR, + self.entry.data[CONF_KNX_KNXKEY_FILENAME], + ) + if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None + else None + ) + if _conn_type == CONF_KNX_ROUTING: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], + multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], + multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING_TCP: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING_TCP, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING_TCP_SECURE, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + secure_config=SecureConfig( + user_id=self.entry.data.get(CONF_KNX_SECURE_USER_ID), + user_password=self.entry.data.get(CONF_KNX_SECURE_USER_PASSWORD), + device_authentication_password=self.entry.data.get( + CONF_KNX_SECURE_DEVICE_AUTHENTICATION + ), + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + auto_reconnect=True, + threaded=True, + ) + if _conn_type == CONF_KNX_ROUTING_SECURE: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING_SECURE, + individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], + multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], + multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + secure_config=SecureConfig( + backbone_key=self.entry.data.get(CONF_KNX_ROUTING_BACKBONE_KEY), + latency_ms=self.entry.data.get( + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE + ), + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + auto_reconnect=True, + threaded=True, + ) + return ConnectionConfig( + auto_reconnect=True, + individual_address=self.entry.data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload + ), + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + + def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + """Call invoked after a KNX connection state change was received.""" + self.connected = state == XknxConnectionState.CONNECTED + for device in self.xknx.devices: + device.after_update() + + def telegram_received_cb(self, telegram: Telegram) -> None: + """Call invoked after a KNX telegram was received.""" + # Not all telegrams have serializable data. + data: int | tuple[int, ...] | None = None + value = None + if ( + isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) + and telegram.payload.value is not None + and isinstance( + telegram.destination_address, (GroupAddress, InternalGroupAddress) + ) + ): + data = telegram.payload.value.value + if transcoder := ( + self.group_address_transcoder.get(telegram.destination_address) + or next( + ( + _transcoder + for _filter, _transcoder in self._address_filter_transcoder.items() + if _filter.match(telegram.destination_address) + ), + None, + ) + ): + try: + value = transcoder.from_knx(telegram.payload.value) + except (ConversionError, CouldNotParseTelegram) as err: + _LOGGER.warning( + ( + "Error in `knx_event` at decoding type '%s' from" + " telegram %s\n%s" + ), + transcoder.__name__, + telegram, + err, + ) + + self.hass.bus.async_fire( + "knx_event", + { + "data": data, + "destination": str(telegram.destination_address), + "direction": telegram.direction.value, + "value": value, + "source": str(telegram.source_address), + "telegramtype": telegram.payload.__class__.__name__, + }, + ) + + def register_event_callback(self) -> TelegramQueue.Callback: + """Register callback for knx_event within XKNX TelegramQueue.""" + address_filters = [] + for filter_set in self.config_yaml[CONF_EVENT]: + _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) + address_filters.extend(_filters) + if (dpt := filter_set.get(CONF_TYPE)) and ( + transcoder := DPTBase.parse_transcoder(dpt) + ): + self._address_filter_transcoder.update( + dict.fromkeys(_filters, transcoder) + ) + + return self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, + address_filters=address_filters, + group_addresses=[], + match_for_outgoing=True, + ) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 865cfdc6e25..1ab6883a437 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP lights.""" +"""Support for KNX light entities.""" from __future__ import annotations @@ -28,14 +28,14 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType from homeassistant.util import color as color_util -from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import LightSchema from .storage.const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, - CONF_DPT, CONF_ENTITY, CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_SWITCH, @@ -45,17 +45,15 @@ from .storage.const import ( CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_SWITCH, CONF_GA_HUE, - CONF_GA_PASSIVE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, - CONF_GA_STATE, CONF_GA_SWITCH, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, - CONF_GA_WRITE, ) from .storage.entity_store_schema import LightColorMode +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -203,94 +201,110 @@ def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight: def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight: """Return a KNX Light device to be used within XKNX.""" - def get_write(key: str) -> str | None: - """Get the write group address.""" - return knx_config[key][CONF_GA_WRITE] if key in knx_config else None - - def get_state(key: str) -> list[Any] | None: - """Get the state group address.""" - return ( - [knx_config[key][CONF_GA_STATE], *knx_config[key][CONF_GA_PASSIVE]] - if key in knx_config - else None - ) - - def get_dpt(key: str) -> str | None: - """Get the DPT.""" - return knx_config[key].get(CONF_DPT) if key in knx_config else None + conf = ConfigExtractor(knx_config) group_address_tunable_white = None group_address_tunable_white_state = None group_address_color_temp = None group_address_color_temp_state = None + color_temperature_type = ColorTemperatureType.UINT_2_BYTE - if ga_color_temp := knx_config.get(CONF_GA_COLOR_TEMP): - if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE.value: - group_address_tunable_white = ga_color_temp[CONF_GA_WRITE] - group_address_tunable_white_state = [ - ga_color_temp[CONF_GA_STATE], - *ga_color_temp[CONF_GA_PASSIVE], - ] + if _color_temp_dpt := conf.get_dpt(CONF_GA_COLOR_TEMP): + if _color_temp_dpt == ColorTempModes.RELATIVE.value: + group_address_tunable_white = conf.get_write(CONF_GA_COLOR_TEMP) + group_address_tunable_white_state = conf.get_state_and_passive( + CONF_GA_COLOR_TEMP + ) else: # absolute uint or float - group_address_color_temp = ga_color_temp[CONF_GA_WRITE] - group_address_color_temp_state = [ - ga_color_temp[CONF_GA_STATE], - *ga_color_temp[CONF_GA_PASSIVE], - ] - if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT.value: + group_address_color_temp = conf.get_write(CONF_GA_COLOR_TEMP) + group_address_color_temp_state = conf.get_state_and_passive( + CONF_GA_COLOR_TEMP + ) + if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value: color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE - _color_dpt = get_dpt(CONF_GA_COLOR) + color_dpt = conf.get_dpt(CONF_COLOR, CONF_GA_COLOR) + return XknxLight( xknx, name=name, - group_address_switch=get_write(CONF_GA_SWITCH), - group_address_switch_state=get_state(CONF_GA_SWITCH), - group_address_brightness=get_write(CONF_GA_BRIGHTNESS), - group_address_brightness_state=get_state(CONF_GA_BRIGHTNESS), - group_address_color=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGB - else None, - group_address_color_state=get_state(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGB - else None, - group_address_rgbw=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGBW - else None, - group_address_rgbw_state=get_state(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGBW - else None, - group_address_hue=get_write(CONF_GA_HUE), - group_address_hue_state=get_state(CONF_GA_HUE), - group_address_saturation=get_write(CONF_GA_SATURATION), - group_address_saturation_state=get_state(CONF_GA_SATURATION), - group_address_xyy_color=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.XYY - else None, - group_address_xyy_color_state=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.XYY - else None, + group_address_switch=conf.get_write(CONF_GA_SWITCH), + group_address_switch_state=conf.get_state_and_passive(CONF_GA_SWITCH), + group_address_brightness=conf.get_write(CONF_GA_BRIGHTNESS), + group_address_brightness_state=conf.get_state_and_passive(CONF_GA_BRIGHTNESS), + group_address_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_rgbw=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_rgbw_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_hue=conf.get_write(CONF_COLOR, CONF_GA_HUE), + group_address_hue_state=conf.get_state_and_passive(CONF_COLOR, CONF_GA_HUE), + group_address_saturation=conf.get_write(CONF_COLOR, CONF_GA_SATURATION), + group_address_saturation_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_SATURATION + ), + group_address_xyy_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), + group_address_xyy_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), group_address_tunable_white=group_address_tunable_white, group_address_tunable_white_state=group_address_tunable_white_state, group_address_color_temperature=group_address_color_temp, group_address_color_temperature_state=group_address_color_temp_state, - group_address_switch_red=get_write(CONF_GA_RED_SWITCH), - group_address_switch_red_state=get_state(CONF_GA_RED_SWITCH), - group_address_brightness_red=get_write(CONF_GA_RED_BRIGHTNESS), - group_address_brightness_red_state=get_state(CONF_GA_RED_BRIGHTNESS), - group_address_switch_green=get_write(CONF_GA_GREEN_SWITCH), - group_address_switch_green_state=get_state(CONF_GA_GREEN_SWITCH), - group_address_brightness_green=get_write(CONF_GA_GREEN_BRIGHTNESS), - group_address_brightness_green_state=get_state(CONF_GA_GREEN_BRIGHTNESS), - group_address_switch_blue=get_write(CONF_GA_BLUE_SWITCH), - group_address_switch_blue_state=get_state(CONF_GA_BLUE_SWITCH), - group_address_brightness_blue=get_write(CONF_GA_BLUE_BRIGHTNESS), - group_address_brightness_blue_state=get_state(CONF_GA_BLUE_BRIGHTNESS), - group_address_switch_white=get_write(CONF_GA_WHITE_SWITCH), - group_address_switch_white_state=get_state(CONF_GA_WHITE_SWITCH), - group_address_brightness_white=get_write(CONF_GA_WHITE_BRIGHTNESS), - group_address_brightness_white_state=get_state(CONF_GA_WHITE_BRIGHTNESS), + group_address_switch_red=conf.get_write(CONF_COLOR, CONF_GA_RED_SWITCH), + group_address_switch_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_SWITCH + ), + group_address_brightness_red=conf.get_write(CONF_COLOR, CONF_GA_RED_BRIGHTNESS), + group_address_brightness_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_BRIGHTNESS + ), + group_address_switch_green=conf.get_write(CONF_COLOR, CONF_GA_GREEN_SWITCH), + group_address_switch_green_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_GREEN_SWITCH + ), + group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), + group_address_brightness_green_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS + ), + group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), + group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), + group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), + group_address_brightness_blue_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS + ), + group_address_switch_white=conf.get_write(CONF_COLOR, CONF_GA_WHITE_SWITCH), + group_address_switch_white_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_WHITE_SWITCH + ), + group_address_brightness_white=conf.get_write( + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS + ), + group_address_brightness_white_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS + ), color_temperature_type=color_temperature_type, min_kelvin=knx_config[CONF_COLOR_TEMP_MIN], max_kelvin=knx_config[CONF_COLOR_TEMP_MAX], diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index baa830bfaa4..312ea56972f 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.4.1.91934" + "knx-frontend==2025.8.9.63154" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 97980ab3d36..d64bac80d9d 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP notifications.""" +"""Support for KNX notify entities.""" from __future__ import annotations @@ -12,9 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 67e8778accc..30efb5e01ee 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP numeric values.""" +"""Support for KNX number entities.""" from __future__ import annotations @@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import NumberSchema diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index b4b36213c43..9e24cc1ce5b 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -104,7 +104,7 @@ rules: Since all entities are configured manually, names are user-defined. exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index f5361a6e7da..39e627ca8ff 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -1,4 +1,4 @@ -"""Support for KNX scenes.""" +"""Support for KNX scene entities.""" from __future__ import annotations @@ -13,9 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SceneSchema diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index e80fa66f9d4..0dc2584876d 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP select entities.""" +"""Support for KNX select entities.""" from __future__ import annotations @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, @@ -30,6 +29,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SelectSchema diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 8e537ea234e..e75d1f180e2 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP sensors.""" +"""Support for KNX sensor entities.""" from __future__ import annotations @@ -33,9 +33,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum -from . import KNXModule from .const import ATTR_SOURCE, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SensorSchema SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 7b8c7ec2371..f63612f97ef 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -35,13 +35,13 @@ from .expose import create_knx_exposure from .schema import ExposeSchema, dpt_base_type_validator, ga_validator if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule _LOGGER = logging.getLogger(__name__) @callback -def register_knx_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Register KNX integration services.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/knx/storage/__init__.py b/homeassistant/components/knx/storage/__init__.py index 25d84406d03..a588a3d154e 100644 --- a/homeassistant/components/knx/storage/__init__.py +++ b/homeassistant/components/knx/storage/__init__.py @@ -1 +1 @@ -"""Helpers for KNX.""" +"""Handle persistent storage for the KNX integration.""" diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2899448a128..2e93256de47 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -13,10 +13,11 @@ from homeassistant.util.ulid import ulid_now from ..const import DOMAIN from .const import CONF_DATA +from .migration import migrate_1_to_2 _LOGGER = logging.getLogger(__name__) -STORAGE_VERSION: Final = 1 +STORAGE_VERSION: Final = 2 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -45,6 +46,20 @@ class PlatformControllerBase(ABC): """Update an existing entities configuration.""" +class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]): + """Storage handler for KNXConfigStore.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + # version 2 introduced in 2025.8 + migrate_1_to_2(old_data) + + return old_data + + class KNXConfigStore: """Manage KNX config store data.""" @@ -56,7 +71,7 @@ class KNXConfigStore: """Initialize config store.""" self.hass = hass self.config_entry = config_entry - self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY) self.data = KNXConfigStoreModel(entities={}) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 7cae0e9bbf6..78cd38c9d00 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -2,6 +2,7 @@ from typing import Final +# Common CONF_DATA: Final = "data" CONF_ENTITY: Final = "entity" CONF_DEVICE_INFO: Final = "device_info" @@ -12,10 +13,22 @@ CONF_DPT: Final = "dpt" CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" -CONF_GA_COLOR_TEMP: Final = "ga_color_temp" + +# Cover +CONF_GA_UP_DOWN: Final = "ga_up_down" +CONF_GA_STOP: Final = "ga_stop" +CONF_GA_STEP: Final = "ga_step" +CONF_GA_POSITION_SET: Final = "ga_position_set" +CONF_GA_POSITION_STATE: Final = "ga_position_state" +CONF_GA_ANGLE: Final = "ga_angle" + +# Light CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MAX: Final = "color_temp_max" CONF_GA_BRIGHTNESS: Final = "ga_brightness" +CONF_GA_COLOR_TEMP: Final = "ga_color_temp" +# Light/color +CONF_COLOR: Final = "color" CONF_GA_COLOR: Final = "ga_color" CONF_GA_RED_BRIGHTNESS: Final = "ga_red_brightness" CONF_GA_RED_SWITCH: Final = "ga_red_switch" @@ -27,9 +40,3 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness" CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" -CONF_GA_UP_DOWN: Final = "ga_up_down" -CONF_GA_STOP: Final = "ga_stop" -CONF_GA_STEP: Final = "ga_step" -CONF_GA_POSITION_SET: Final = "ga_position_set" -CONF_GA_POSITION_STATE: Final = "ga_position_state" -CONF_GA_ANGLE: Final = "ga_angle" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 85bcbd1809f..6c41a7d29e7 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -29,6 +29,7 @@ from ..const import ( ) from ..validation import sync_state_validator from .const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_DATA, @@ -43,23 +44,20 @@ from .const import ( CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_SWITCH, CONF_GA_HUE, - CONF_GA_PASSIVE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SENSOR, - CONF_GA_STATE, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_SWITCH, CONF_GA_UP_DOWN, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, - CONF_GA_WRITE, ) -from .knx_selector import GASelector +from .knx_selector import GASelector, GroupSelect BASE_ENTITY_SCHEMA = vol.All( { @@ -87,24 +85,6 @@ BASE_ENTITY_SCHEMA = vol.All( ) -def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: - """Validate group address schema or remove key if no address is set.""" - # frontend will return {key: {"write": None, "state": None}} for unused GA sets - # -> remove this entirely for optional keys - # if one GA is set, validate as usual - return { - vol.Optional(key): ga_selector, - vol.Remove(key): vol.Schema( - { - vol.Optional(CONF_GA_WRITE): None, - vol.Optional(CONF_GA_STATE): None, - vol.Optional(CONF_GA_PASSIVE): vol.IsFalse(), # None or empty list - }, - extra=vol.ALLOW_EXTRA, - ), - } - - BINARY_SENSOR_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -134,16 +114,14 @@ COVER_SCHEMA = vol.Schema( vol.Required(DOMAIN): vol.All( vol.Schema( { - **optional_ga_schema(CONF_GA_UP_DOWN, GASelector(state=False)), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_STOP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_STEP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_POSITION_SET, GASelector(state=False)), - **optional_ga_schema( - CONF_GA_POSITION_STATE, GASelector(write=False) - ), + vol.Optional(CONF_GA_STOP): GASelector(state=False), + vol.Optional(CONF_GA_STEP): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_ANGLE, GASelector()), + vol.Optional(CONF_GA_ANGLE): GASelector(), vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), vol.Optional( CoverConf.TRAVELLING_TIME_DOWN, default=25 @@ -208,72 +186,111 @@ class LightColorModeSchema(StrEnum): HSV = "hsv" -_LIGHT_COLOR_MODE_SCHEMA = "_light_color_mode_schema" +_hs_color_inclusion_msg = ( + "'Hue', 'Saturation' and 'Brightness' addresses are required for HSV configuration" +) -_COMMON_LIGHT_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - **optional_ga_schema( - CONF_GA_COLOR_TEMP, GASelector(write_required=True, dpt=ColorTempModes) + +LIGHT_KNX_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_GA_BRIGHTNESS): GASelector(write_required=True), + vol.Optional(CONF_GA_COLOR_TEMP): GASelector( + write_required=True, dpt=ColorTempModes + ), + vol.Optional(CONF_COLOR): GroupSelect( + vol.Schema( + { + vol.Optional(CONF_GA_COLOR): GASelector( + write_required=True, dpt=LightColorMode + ) + } + ), + vol.Schema( + { + vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_RED_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( + write_required=False + ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( + write_required=False + ), + } + ), + vol.Schema( + { + vol.Required(CONF_GA_HUE): GASelector(write_required=True), + vol.Required(CONF_GA_SATURATION): GASelector( + write_required=True + ), + } + ), + # msg="error in `color` config", + ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } + ), + vol.Any( + vol.Schema( + {vol.Required(CONF_GA_SWITCH): object}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Schema( # brightness addresses are required in INDIVIDUAL_COLOR_SCHEMA + {vol.Required(CONF_COLOR): {vol.Required(CONF_GA_RED_BRIGHTNESS): object}}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( - vol.Coerce(int), vol.Range(min=1) + msg="either 'address' or 'individual_colors' is required", + ), + vol.Any( + vol.Schema( # 'brightness' is non-optional for hs-color + { + vol.Required(CONF_GA_BRIGHTNESS, msg=_hs_color_inclusion_msg): object, + vol.Required(CONF_COLOR): { + vol.Required(CONF_GA_HUE, msg=_hs_color_inclusion_msg): object, + vol.Required( + CONF_GA_SATURATION, msg=_hs_color_inclusion_msg + ): object, + }, + }, + extra=vol.ALLOW_EXTRA, ), - }, - extra=vol.REMOVE_EXTRA, -) - -_DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.DEFAULT.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema( - CONF_GA_COLOR, - GASelector(write_required=True, dpt=LightColorMode), + vol.Schema( # hs-colors not used + { + vol.Optional(CONF_COLOR): { + vol.Optional(CONF_GA_HUE): None, + vol.Optional(CONF_GA_SATURATION): None, + }, + }, + extra=vol.ALLOW_EXTRA, ), - } + msg=_hs_color_inclusion_msg, + ), ) -_INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.INDIVIDUAL.value, - **optional_ga_schema(CONF_GA_SWITCH, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_RED_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_GREEN_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BLUE_SWITCH, GASelector(write_required=False)), - **optional_ga_schema(CONF_GA_WHITE_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_WHITE_SWITCH, GASelector(write_required=False)), - } -) - -_HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.HSV.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Required(CONF_GA_BRIGHTNESS): GASelector(write_required=True), - vol.Required(CONF_GA_HUE): GASelector(write_required=True), - vol.Required(CONF_GA_SATURATION): GASelector(write_required=True), - } -) - - -LIGHT_KNX_SCHEMA = cv.key_value_schemas( - _LIGHT_COLOR_MODE_SCHEMA, - default_schema=_DEFAULT_LIGHT_SCHEMA, - value_schemas={ - LightColorModeSchema.DEFAULT: _DEFAULT_LIGHT_SCHEMA, - LightColorModeSchema.INDIVIDUAL: _INDIVIDUAL_LIGHT_SCHEMA, - LightColorModeSchema.HSV: _HSV_LIGHT_SCHEMA, - }, -) LIGHT_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py index 9bad5297853..1da7b58378d 100644 --- a/homeassistant/components/knx/storage/entity_store_validation.py +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -1,4 +1,4 @@ -"""KNX Entity Store Validation.""" +"""KNX entity store validation.""" from typing import Literal, TypedDict diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index a1510dbb384..fe909f1fd0a 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -1,5 +1,6 @@ """Selectors for KNX.""" +from collections.abc import Hashable, Iterable from enum import Enum from typing import Any @@ -9,6 +10,31 @@ from ..validation import ga_validator, maybe_ga_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE +class GroupSelect(vol.Any): + """Use the first validated value. + + This is a version of vol.Any with custom error handling to + show proper invalid markers for sub-schema items in the UI. + """ + + def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> Any: + """Execute the validation functions.""" + errors: list[vol.Invalid] = [] + for func in funcs: + try: + if path is None: + return func(v) + return func(path, v) + except vol.Invalid as e: + errors.append(e) + if errors: + raise next( + (err for err in errors if "extra keys not allowed" not in err.msg), + errors[0], + ) + raise vol.AnyInvalid(self.msg or "no valid value found", path=path) + + class GASelector: """Selector for a KNX group address structure.""" diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py new file mode 100644 index 00000000000..f7d7941e5cc --- /dev/null +++ b/homeassistant/components/knx/storage/migration.py @@ -0,0 +1,42 @@ +"""Migration functions for KNX config store schema.""" + +from typing import Any + +from homeassistant.const import Platform + +from . import const as store_const + + +def migrate_1_to_2(data: dict[str, Any]) -> None: + """Migrate from schema 1 to schema 2.""" + if lights := data.get("entities", {}).get(Platform.LIGHT): + for light in lights.values(): + _migrate_light_schema_1_to_2(light["knx"]) + + +def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None: + """Migrate light color mode schema.""" + # Remove no more needed helper data from schema + light_knx_data.pop("_light_color_mode_schema", None) + + # Move color related group addresses to new "color" key + color: dict[str, Any] = {} + for color_key in ( + # optional / required and exclusive keys are the same in old and new schema + store_const.CONF_GA_COLOR, + store_const.CONF_GA_HUE, + store_const.CONF_GA_SATURATION, + store_const.CONF_GA_RED_BRIGHTNESS, + store_const.CONF_GA_RED_SWITCH, + store_const.CONF_GA_GREEN_BRIGHTNESS, + store_const.CONF_GA_GREEN_SWITCH, + store_const.CONF_GA_BLUE_BRIGHTNESS, + store_const.CONF_GA_BLUE_SWITCH, + store_const.CONF_GA_WHITE_BRIGHTNESS, + store_const.CONF_GA_WHITE_SWITCH, + ): + if color_key in light_knx_data: + color[color_key] = light_knx_data.pop(color_key) + + if color: + light_knx_data[store_const.CONF_COLOR] = color diff --git a/homeassistant/components/knx/storage/util.py b/homeassistant/components/knx/storage/util.py new file mode 100644 index 00000000000..a3831070a7e --- /dev/null +++ b/homeassistant/components/knx/storage/util.py @@ -0,0 +1,51 @@ +"""Utility functions for the KNX integration.""" + +from functools import partial +from typing import Any + +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE + + +def nested_get(dic: ConfigType, *keys: str, default: Any | None = None) -> Any: + """Get the value from a nested dictionary.""" + for key in keys: + if key not in dic: + return default + dic = dic[key] + return dic + + +class ConfigExtractor: + """Helper class for extracting values from a knx config store dictionary.""" + + __slots__ = ("get",) + + def __init__(self, config: ConfigType) -> None: + """Initialize the extractor.""" + self.get = partial(nested_get, config) + + def get_write(self, *path: str) -> str | None: + """Get the write group address.""" + return self.get(*path, CONF_GA_WRITE) # type: ignore[no-any-return] + + def get_state(self, *path: str) -> str | None: + """Get the state group address.""" + return self.get(*path, CONF_GA_STATE) # type: ignore[no-any-return] + + def get_write_and_passive(self, *path: str) -> list[Any | None]: + """Get the group addresses of write and passive.""" + write = self.get(*path, CONF_GA_WRITE) + passive = self.get(*path, CONF_GA_PASSIVE) + return [write, *passive] if passive else [write] + + def get_state_and_passive(self, *path: str) -> list[Any | None]: + """Get the group addresses of state and passive.""" + state = self.get(*path, CONF_GA_STATE) + passive = self.get(*path, CONF_GA_PASSIVE) + return [state, *passive] if passive else [state] + + def get_dpt(self, *path: str) -> str | None: + """Get the data point type of a group address config key.""" + return self.get(*path, CONF_DPT) # type: ignore[no-any-return] diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index dc4d7de42ff..921fc2c5288 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reconfigure": { + "title": "KNX connection settings", + "menu_options": { + "connection_type": "Reconfigure KNX connection", + "secure_knxkeys": "Import KNX keyring file" + } + }, "connection_type": { "title": "KNX connection", "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", @@ -65,7 +72,7 @@ }, "secure_knxkeys": { "title": "Import KNX Keyring", - "description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.", + "description": "The keyring is used to encrypt and decrypt KNX IP Secure communication. You can import a new keyring file or re-import to update existing keys if your configuration has changed.", "data": { "knxkeys_file": "Keyring file", "knxkeys_password": "Keyring password" @@ -129,6 +136,9 @@ } } }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.", @@ -159,16 +169,8 @@ }, "options": { "step": { - "init": { - "title": "KNX Settings", - "menu_options": { - "connection_type": "Configure KNX interface", - "communication_settings": "Communication settings", - "secure_knxkeys": "Import a `.knxkeys` file" - } - }, "communication_settings": { - "title": "[%key:component::knx::options::step::init::menu_options::communication_settings%]", + "title": "Communication settings", "data": { "state_updater": "State updater", "rate_limit": "Rate limit", @@ -179,147 +181,7 @@ "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } - }, - "connection_type": { - "title": "[%key:component::knx::config::step::connection_type::title%]", - "description": "[%key:component::knx::config::step::connection_type::description%]", - "data": { - "connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]" - }, - "data_description": { - "connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]" - } - }, - "tunnel": { - "title": "[%key:component::knx::config::step::tunnel::title%]", - "data": { - "gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]" - }, - "data_description": { - "gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]" - } - }, - "tcp_tunnel_endpoint": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "manual_tunnel": { - "title": "[%key:component::knx::config::step::manual_tunnel::title%]", - "description": "[%key:component::knx::config::step::manual_tunnel::description%]", - "data": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data::tunneling_type%]", - "port": "[%key:common::config_flow::data::port%]", - "host": "[%key:common::config_flow::data::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]", - "port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]", - "host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } - }, - "secure_key_source_menu_tunnel": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_tunnel_manual%]" - } - }, - "secure_key_source_menu_routing": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]" - } - }, - "secure_knxkeys": { - "title": "[%key:component::knx::config::step::secure_knxkeys::title%]", - "description": "[%key:component::knx::config::step::secure_knxkeys::description%]", - "data": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" - }, - "data_description": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" - } - }, - "knxkeys_tunnel_select": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "secure_tunnel_manual": { - "title": "[%key:component::knx::config::step::secure_tunnel_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data::device_authentication%]" - }, - "data_description": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::device_authentication%]" - } - }, - "secure_routing_manual": { - "title": "[%key:component::knx::config::step::secure_routing_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data::sync_latency_tolerance%]" - }, - "data_description": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data_description::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data_description::sync_latency_tolerance%]" - } - }, - "routing": { - "title": "[%key:component::knx::config::step::routing::title%]", - "description": "[%key:component::knx::config::step::routing::description%]", - "data": { - "individual_address": "[%key:component::knx::config::step::routing::data::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_backbone_key": "[%key:component::knx::config::error::invalid_backbone_key%]", - "invalid_individual_address": "[%key:component::knx::config::error::invalid_individual_address%]", - "invalid_ip_address": "[%key:component::knx::config::error::invalid_ip_address%]", - "keyfile_no_backbone_key": "[%key:component::knx::config::error::keyfile_no_backbone_key%]", - "keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]", - "keyfile_no_tunnel_for_host": "[%key:component::knx::config::error::keyfile_no_tunnel_for_host%]", - "keyfile_not_found": "[%key:component::knx::config::error::keyfile_not_found%]", - "no_router_discovered": "[%key:component::knx::config::error::no_router_discovered%]", - "no_tunnel_discovered": "[%key:component::knx::config::error::no_tunnel_discovered%]", - "unsupported_tunnel_type": "[%key:component::knx::config::error::unsupported_tunnel_type%]" } }, "entity": { diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 730c5b788ff..4d6ca288dc6 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP switches.""" +"""Support for KNX switch entities.""" from __future__ import annotations @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_INVERT, CONF_RESPOND_TO_READ, @@ -35,14 +34,10 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import SwitchSchema -from .storage.const import ( - CONF_ENTITY, - CONF_GA_PASSIVE, - CONF_GA_STATE, - CONF_GA_SWITCH, - CONF_GA_WRITE, -) +from .storage.const import CONF_ENTITY, CONF_GA_SWITCH +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -142,15 +137,13 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity): unique_id=unique_id, entity_config=config[CONF_ENTITY], ) + knx_conf = ConfigExtractor(config[DOMAIN]) self._device = XknxSwitch( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE], - group_address_state=[ - config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE], - *config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE], - ], - respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ], - sync_state=config[DOMAIN][CONF_SYNC_STATE], - invert=config[DOMAIN][CONF_INVERT], + group_address=knx_conf.get_write(CONF_GA_SWITCH), + group_address_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH), + respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), + sync_state=knx_conf.get(CONF_SYNC_STATE), + invert=knx_conf.get(CONF_INVERT), ) diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 9c2bb88f92b..14c9af11ad3 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP text.""" +"""Support for KNX text entities.""" from __future__ import annotations @@ -22,9 +22,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 2c74ab18af3..3bc171cae31 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP time.""" +"""Support for KNX time entities.""" from __future__ import annotations @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -31,6 +30,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/trigger.py b/homeassistant/components/knx/trigger.py index ae3ba088357..ba8bfff5d3b 100644 --- a/homeassistant/components/knx/trigger.py +++ b/homeassistant/components/knx/trigger.py @@ -1,4 +1,4 @@ -"""Offer knx telegram automation triggers.""" +"""Provide KNX automation triggers.""" from typing import Final diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 342ab445611..e8f0036f5bb 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP weather station.""" +"""Support for KNX weather entities.""" from __future__ import annotations @@ -19,9 +19,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import WeatherSchema diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 9ba3e0ccff6..b40dc2246b8 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -2,9 +2,9 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from functools import wraps +import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel @@ -36,7 +36,7 @@ from .storage.entity_store_validation import ( from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule URL_BASE: Final = "/knx_static" @@ -116,7 +116,7 @@ def provide_knx( "KNX integration not loaded.", ) - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @wraps(func) async def with_knx( diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 44eced7ca4a..feeb4bc5bb5 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import CONF_SERVICE_CODE from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,6 +30,7 @@ class PlenticoreSwitchEntityDescription(SwitchEntityDescription): on_label: str off_value: str off_label: str + installer_required: bool = False SWITCH_SETTINGS_DATA = [ @@ -42,6 +44,17 @@ SWITCH_SETTINGS_DATA = [ off_value="2", off_label="Automatic economical", ), + PlenticoreSwitchEntityDescription( + module_id="devices:local", + key="Battery:ManualCharge", + name="Battery Manual Charge", + is_on="1", + on_value="1", + on_label="On", + off_value="0", + off_label="Off", + installer_required=True, + ), ] @@ -73,7 +86,13 @@ async def async_setup_entry( description.key, ) continue - + if entry.data.get(CONF_SERVICE_CODE) is None and description.installer_required: + _LOGGER.debug( + "Skipping installer required setting data %s/%s", + description.module_id, + description.key, + ) + continue entities.append( PlenticoreDataSwitch( settings_data_update_coordinator, diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index c981f3fd438..5c3158bddf2 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -135,6 +135,7 @@ class KrakenData: 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] diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 1499dd02900..c6f3c2312c0 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -42,7 +42,6 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): self.last_update = time() self.username = entry.data["username"] self.password = entry.data["password"] - self.hass = hass self.name = entry.data["name"] self.id = entry.data["id"] super().__init__( diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index ff977438f38..92184b4ac51 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import ( @@ -57,11 +57,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id - client = async_get_clientsession(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], - client=client, + client=async_create_clientsession(hass), ) try: @@ -155,13 +154,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener( - hass: HomeAssistant, entry: LaMarzoccoConfigEntry - ) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 4fc2c0b05df..afbb779b696 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -66,7 +66,7 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( WidgetType.CM_BACK_FLUSH, BackFlush(status=BackFlushStatus.OFF) ), ).status - is BackFlushStatus.REQUESTED + in (BackFlushStatus.REQUESTED, BackFlushStatus.CLEANING) ), entity_category=EntityCategory.DIAGNOSTIC, supported_fn=lambda coordinator: ( diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 8cb2e4dfc61..fb968a0b4af 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ADDRESS, @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -83,7 +83,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): **user_input, } - self._client = async_get_clientsession(self.hass) + self._client = async_create_clientsession(self.hass) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], @@ -363,7 +363,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): return LmOptionsFlowHandler() -class LmOptionsFlowHandler(OptionsFlow): +class LmOptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init( diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 6dc024645ce..6f9de083286 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,8 +2,10 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, MachineState, WidgetType +from pylamarzocco.models import MachineStatus from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( @@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity( """Common elements for all entities.""" _attr_has_entity_name = True + _unavailable_when_machine_off = True def __init__( self, @@ -63,6 +66,21 @@ class LaMarzoccoBaseEntity( if connections: self._attr_device_info.update(DeviceInfo(connections=connections)) + @property + def available(self) -> bool: + """Return True if entity is available.""" + machine_state = ( + cast( + MachineStatus, + self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS], + ).status + if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config + else MachineState.OFF + ) + return super().available and not ( + self._unavailable_when_machine_off and machine_state is MachineState.OFF + ) + class LaMarzoccoEntity(LaMarzoccoBaseEntity): """Common elements for all entities.""" diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 7fdafc4dda1..3c070769b5b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.9"] + "requirements": ["pylamarzocco==2.0.11"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index f8cb8b1d6fe..b235cc7c5f9 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index c76f51c3488..1f4983a03a8 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -56,11 +56,14 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( CoffeeBoiler, config[WidgetType.CM_COFFEE_BOILER] ).ready_start_time ), - entity_category=EntityCategory.DIAGNOSTIC, available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config + lambda coordinator: cast( + CoffeeBoiler, + coordinator.device.dashboard.config[WidgetType.CM_COFFEE_BOILER], + ).ready_start_time + is not None ), + entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", @@ -71,11 +74,18 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( SteamBoilerLevel, config[WidgetType.CM_STEAM_BOILER_LEVEL] ).ready_start_time ), - entity_category=EntityCategory.DIAGNOSTIC, supported_fn=( lambda coordinator: coordinator.device.dashboard.model_name in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R) ), + available_fn=( + lambda coordinator: cast( + SteamBoilerLevel, + coordinator.device.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL], + ).ready_start_time + is not None + ), + entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( key="brewing_start_time", @@ -188,6 +198,8 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity): """Sensor for La Marzocco statistics.""" + _unavailable_when_machine_off = False + @property def native_value(self) -> StateType | datetime | None: """Return the value of the sensor.""" diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index dbf25f6680b..f3fa1e81112 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "choice_enter_manual_or_fetch_cloud": { - "description": "A LaMetric device can be set up in Home Assistant in two different ways.\n\nYou can enter all device information and API tokens yourself, or Home Asssistant can import them from your LaMetric.com account.", + "description": "A LaMetric device can be set up in Home Assistant in two different ways.\n\nYou can enter all device information and API tokens yourself, or Home Assistant can import them from your LaMetric.com account.", "menu_options": { "pick_implementation": "Import from LaMetric.com (recommended)", "manual_entry": "Enter manually" diff --git a/homeassistant/components/lametric/update.py b/homeassistant/components/lametric/update.py index d486d9d27ba..3d93f919c58 100644 --- a/homeassistant/components/lametric/update.py +++ b/homeassistant/components/lametric/update.py @@ -42,5 +42,5 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity): def latest_version(self) -> str | None: """Return the latest version of the entity.""" if not self.coordinator.data.update: - return None + return self.coordinator.data.os_version return self.coordinator.data.update.version diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index b5a4612429e..90bee0cf4e7 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -16,7 +16,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -24,8 +23,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bool: """Unload lastfm config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: LastFMConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 422c50a5fb9..47c5b0e217e 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -155,7 +159,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) -class LastFmOptionsFlowHandler(OptionsFlow): +class LastFmOptionsFlowHandler(OptionsFlowWithReload): """LastFm Options flow handler.""" config_entry: LastFMConfigEntry diff --git a/homeassistant/components/launch_library/strings.json b/homeassistant/components/launch_library/strings.json index a587544f836..219d71600bc 100644 --- a/homeassistant/components/launch_library/strings.json +++ b/homeassistant/components/launch_library/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Do you want to configure the Launch Library?" + "description": "Do you want to configure Launch Library?" } } }, diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json index 481900775ae..600e6a9bdf0 100644 --- a/homeassistant/components/laundrify/strings.json +++ b/homeassistant/components/laundrify/strings.json @@ -9,7 +9,7 @@ "config": { "step": { "init": { - "description": "Please enter your personal Auth Code that is shown in the laundrify-App.", + "description": "Please enter your personal Auth Code that is shown in the laundrify app.", "data": { "code": "Auth Code (xxx-xxx)" } diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 43438fa64dd..77d1bb4e709 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -104,7 +104,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) - ) as ex: await lcn_connection.async_close() raise ConfigEntryNotReady( - f"Unable to connect to {config_entry.title}: {ex}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "config_entry_title": config_entry.title, + }, ) from ex _LOGGER.info('LCN connected to "%s"', config_entry.title) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index b124b3f6188..a9f194fe1b8 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -5,23 +5,16 @@ from functools import partial import pypck -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as DOMAIN_BINARY_SENSOR, BinarySensorEntity, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import ConfigType -from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, DOMAIN, SETPOINTS +from .const import CONF_DOMAIN_DATA from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry @@ -34,15 +27,9 @@ def add_lcn_entities( entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" - entities: list[LcnRegulatorLockSensor | LcnBinarySensor | LcnLockKeysSensor] = [] - for entity_config in entity_configs: - if entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in SETPOINTS: - entities.append(LcnRegulatorLockSensor(entity_config, config_entry)) - elif entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in BINSENSOR_PORTS: - entities.append(LcnBinarySensor(entity_config, config_entry)) - else: # in KEY - entities.append(LcnLockKeysSensor(entity_config, config_entry)) - + entities = [ + LcnBinarySensor(entity_config, config_entry) for entity_config in entity_configs + ] async_add_entities(entities) @@ -71,65 +58,6 @@ async def async_setup_entry( ) -class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): - """Representation of a LCN binary sensor for regulator locks.""" - - def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: - """Initialize the LCN binary sensor.""" - super().__init__(config, config_entry) - - self.setpoint_variable = pypck.lcn_defs.Var[ - config[CONF_DOMAIN_DATA][CONF_SOURCE] - ] - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler( - self.setpoint_variable - ) - - entity_automations = automations_with_entity(self.hass, self.entity_id) - entity_scripts = scripts_with_entity(self.hass, self.entity_id) - if entity_automations + entity_scripts: - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_regulatorlock_sensor", - translation_placeholders={ - "entity": f"{DOMAIN_BINARY_SENSOR}.{self.name.lower().replace(' ', '_')}", - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler( - self.setpoint_variable - ) - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" - ) - - def input_received(self, input_obj: InputType) -> None: - """Set sensor value when LCN input object (command) is received.""" - if ( - not isinstance(input_obj, pypck.inputs.ModStatusVar) - or input_obj.get_var() != self.setpoint_variable - ): - return - - self._attr_is_on = input_obj.get_value().is_locked_regulator() - self.async_write_ha_state() - - class LcnBinarySensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for binary sensor ports.""" @@ -164,59 +92,3 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): self._attr_is_on = input_obj.get_state(self.bin_sensor_port.value) self.async_write_ha_state() - - -class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): - """Representation of a LCN sensor for key locks.""" - - def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: - """Initialize the LCN sensor.""" - super().__init__(config, config_entry) - - self.source = pypck.lcn_defs.Key[config[CONF_DOMAIN_DATA][CONF_SOURCE]] - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.source) - - entity_automations = automations_with_entity(self.hass, self.entity_id) - entity_scripts = scripts_with_entity(self.hass, self.entity_id) - if entity_automations + entity_scripts: - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_keylock_sensor", - translation_placeholders={ - "entity": f"{DOMAIN_BINARY_SENSOR}.{self.name.lower().replace(' ', '_')}", - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.source) - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" - ) - - def input_received(self, input_obj: InputType) -> None: - """Set sensor value when LCN input object (command) is received.""" - if ( - not isinstance(input_obj, pypck.inputs.ModStatusKeyLocks) - or self.source not in pypck.lcn_defs.Key - ): - return - - table_id = ord(self.source.name[0]) - 65 - key_id = int(self.source.name[1]) - 1 - - self._attr_is_on = input_obj.get_state(table_id, key_id) - self.async_write_ha_state() diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 515f64b6e31..4937b5dbca7 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -26,6 +26,7 @@ from homeassistant.const import ( CONF_SWITCHES, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType @@ -100,7 +101,11 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: return cast(str, domain_data["setpoint"]) if domain_name == "scene": return f"{domain_data['register']}{domain_data['scene']}" - raise ValueError("Unknown domain") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_domain", + translation_placeholders={CONF_DOMAIN: domain_name}, + ) def generate_unique_id( @@ -304,6 +309,8 @@ def get_device_config( def is_states_string(states_string: str) -> list[str]: """Validate the given states string and return states list.""" if len(states_string) != 8: - raise ValueError("Invalid length of states string") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_length_of_states_string" + ) states = {"1": "ON", "0": "OFF", "T": "TOGGLE", "-": "NOCHANGE"} return [states[state_string] for state_string in states_string] diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index cd6b5c7057e..b9dad0aeb19 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import ( CONF_DIMMABLE, @@ -29,6 +30,8 @@ from .const import ( from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry +BRIGHTNESS_SCALE = (1, 100) + PARALLEL_UPDATES = 0 @@ -91,8 +94,6 @@ class LcnOutputLight(LcnEntity, LightEntity): ) self.dimmable = config[CONF_DOMAIN_DATA][CONF_DIMMABLE] - self._is_dimming_to_zero = False - if self.dimmable: self._attr_color_mode = ColorMode.BRIGHTNESS else: @@ -113,10 +114,6 @@ class LcnOutputLight(LcnEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - if ATTR_BRIGHTNESS in kwargs: - percent = int(kwargs[ATTR_BRIGHTNESS] / 255.0 * 100) - else: - percent = 100 if ATTR_TRANSITION in kwargs: transition = pypck.lcn_defs.time_to_ramp_value( kwargs[ATTR_TRANSITION] * 1000 @@ -124,12 +121,23 @@ class LcnOutputLight(LcnEntity, LightEntity): else: transition = self._transition - if not await self.device_connection.dim_output( - self.output.value, percent, transition - ): + if ATTR_BRIGHTNESS in kwargs: + percent = int( + brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]) + ) + if not await self.device_connection.dim_output( + self.output.value, percent, transition + ): + return + elif not self.is_on: + if not await self.device_connection.toggle_output( + self.output.value, transition, to_memory=True + ): + return + else: return + self._attr_is_on = True - self._is_dimming_to_zero = False self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -141,13 +149,13 @@ class LcnOutputLight(LcnEntity, LightEntity): else: transition = self._transition - if not await self.device_connection.dim_output( - self.output.value, 0, transition - ): - return - self._is_dimming_to_zero = bool(transition) - self._attr_is_on = False - self.async_write_ha_state() + if self.is_on: + if not await self.device_connection.toggle_output( + self.output.value, transition, to_memory=True + ): + return + self._attr_is_on = False + self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: """Set light state when LCN input object (command) is received.""" @@ -157,11 +165,9 @@ class LcnOutputLight(LcnEntity, LightEntity): ): return - self._attr_brightness = int(input_obj.get_percent() / 100.0 * 255) - if self._attr_brightness == 0: - self._is_dimming_to_zero = False - if not self._is_dimming_to_zero and self._attr_brightness is not None: - self._attr_is_on = self._attr_brightness > 0 + percent = input_obj.get_percent() + self._attr_brightness = value_to_brightness(BRIGHTNESS_SCALE, percent) + self._attr_is_on = bool(percent) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 9e300716d3e..234178d3e3b 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"] + "quality_scale": "bronze", + "requirements": ["pypck==0.8.10", "lcn-frontend==0.2.6"] } diff --git a/homeassistant/components/lcn/quality_scale.yaml b/homeassistant/components/lcn/quality_scale.yaml new file mode 100644 index 00000000000..35d76a2ebdc --- /dev/null +++ b/homeassistant/components/lcn/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + Integration has no authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: done + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device discovery has to be manually triggered in LCN. Manually adding devices is implemented. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: + status: exempt + comment: | + Since all entities are configured manually, they are enabled by default. + entity-translations: + status: exempt + comment: | + Since all entities are configured manually, names are user-defined. + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + Device discovery has to be manually triggered in LCN. Manually removing devices is implemented. + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Integration is not making any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 15d60639a1c..8a172ccac2e 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -330,8 +330,9 @@ class SendKeys(LcnServiceCall): if (delay_time := service.data[CONF_TIME]) != 0: hit = pypck.lcn_defs.SendKeyCommand.HIT if pypck.lcn_defs.SendKeyCommand[service.data[CONF_STATE]] != hit: - raise ValueError( - "Only hit command is allowed when sending deferred keys." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_send_keys_action", ) delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT]) await device_connection.send_keys_hit_deferred(keys, delay_time, delay_unit) @@ -368,8 +369,9 @@ class LockKeys(LcnServiceCall): if (delay_time := service.data[CONF_TIME]) != 0: if table_id != 0: - raise ValueError( - "Only table A is allowed when locking keys for a specific time." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_lock_keys_table", ) delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT]) await device_connection.lock_keys_tab_a_temporary( diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 9d806bce104..90d4bdcd4ad 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -70,7 +70,7 @@ }, "abort": { "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "already_configured": "PCHK connection using the same ip address/port is already configured." + "already_configured": "PCHK connection using the same IP address/port is already configured." } }, "issues": { @@ -156,7 +156,7 @@ }, "relays": { "name": "Relays", - "description": "Sets the relays status.", + "description": "Sets the relay states.", "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", @@ -168,7 +168,7 @@ }, "state": { "name": "State", - "description": "Relays states as string (1=on, 2=off, t=toggle, -=no change)." + "description": "Relay states as string (1=on, 2=off, t=toggle, -=no change)." } } }, @@ -322,7 +322,7 @@ }, "lock_keys": { "name": "Lock keys", - "description": "Locks keys.", + "description": "Sets the key lock states.", "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", @@ -414,11 +414,23 @@ } }, "exceptions": { - "invalid_address": { - "message": "LCN device for given address has not been configured." + "cannot_connect": { + "message": "Unable to connect to {config_entry_title}." }, "invalid_device_id": { - "message": "LCN device for given device ID has not been configured." + "message": "LCN device for given device ID {device_id} has not been configured." + }, + "invalid_domain": { + "message": "Invalid domain {domain}." + }, + "invalid_send_keys_action": { + "message": "Invalid state for sending keys. Only 'hit' allowed for deferred sending." + }, + "invalid_lock_keys_table": { + "message": "Invalid table for locking keys. Only table A allowed when locking for a specific time." + }, + "invalid_length_of_states_string": { + "message": "Invalid length of states string. Expected 8 characters." } } } diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index ba5ca3bdba4..1efe4e05682 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/leaone/strings.json b/homeassistant/components/leaone/strings.json index bb684941147..53332ce2fec 100644 --- a/homeassistant/components/leaone/strings.json +++ b/homeassistant/components/leaone/strings.json @@ -13,7 +13,7 @@ } }, "abort": { - "no_devices_found": "No supported LeaOne devices found in range; If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.", + "no_devices_found": "No supported LeaOne devices found in range. If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 49daafeca25..3a73c28cdf6 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"] + "requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"] } diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 50c73f949a3..7bcb04b2b4d 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -6,6 +6,7 @@ import asyncio from letpot.client import LetPotClient from letpot.converters import CONVERTERS +from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo @@ -24,6 +25,7 @@ from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, @@ -68,8 +70,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo except LetPotException as exc: raise ConfigEntryNotReady from exc + device_client = LetPotDeviceClient(auth) + coordinators: list[LetPotDeviceCoordinator] = [ - LetPotDeviceCoordinator(hass, entry, auth, device) + LetPotDeviceCoordinator(hass, entry, device, device_client) for device in devices if any(converter.supports_type(device.device_type) for converter in CONVERTERS) ] @@ -92,5 +96,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> b """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for coordinator in entry.runtime_data: - coordinator.device_client.disconnect() + await coordinator.device_client.unsubscribe( + coordinator.device.serial_number + ) return unload_ok diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py index bfc7a5ab4a7..e5939abc24d 100644 --- a/homeassistant/components/letpot/binary_sensor.py +++ b/homeassistant/components/letpot/binary_sensor.py @@ -58,7 +58,9 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, supported_fn=( lambda coordinator: DeviceFeature.PUMP_STATUS - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotBinarySensorEntityDescription( diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index 39e49348663..0ef2c563f38 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -8,7 +8,7 @@ import logging from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException -from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus +from letpot.models import LetPotDevice, LetPotDeviceStatus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,8 +34,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): self, hass: HomeAssistant, config_entry: LetPotConfigEntry, - info: AuthenticationInfo, device: LetPotDevice, + device_client: LetPotDeviceClient, ) -> None: """Initialize coordinator.""" super().__init__( @@ -45,9 +45,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): name=f"LetPot {device.serial_number}", update_interval=timedelta(minutes=10), ) - self._info = info self.device = device - self.device_client = LetPotDeviceClient(info, device.serial_number) + self.device_client = device_client def _handle_status_update(self, status: LetPotDeviceStatus) -> None: """Distribute status update to entities.""" @@ -56,7 +55,9 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): async def _async_setup(self) -> None: """Set up subscription for coordinator.""" try: - await self.device_client.subscribe(self._handle_status_update) + await self.device_client.subscribe( + self.device.serial_number, self._handle_status_update + ) except LetPotAuthenticationException as exc: raise ConfigEntryAuthFailed from exc @@ -64,7 +65,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): """Request an update from the device and wait for a status update or timeout.""" try: async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): - await self.device_client.get_current_status() + await self.device_client.get_current_status(self.device.serial_number) except LetPotException as exc: raise UpdateFailed(exc) from exc diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index 5e2c46fee84..11d6a132a18 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -30,12 +30,13 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: """Initialize a LetPot entity.""" super().__init__(coordinator) + info = coordinator.device_client.device_info(coordinator.device.serial_number) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.device.serial_number)}, name=coordinator.device.name, manufacturer="LetPot", - model=coordinator.device_client.device_model_name, - model_id=coordinator.device_client.device_model_code, + model=info.model_name, + model_id=info.model_code, serial_number=coordinator.device.serial_number, ) diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 43541b57150..1f5e79b04dd 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -20,6 +20,23 @@ } } }, + "select": { + "display_temperature_unit": { + "default": "mdi:thermometer-lines" + }, + "light_brightness": { + "default": "mdi:brightness-6", + "state": { + "high": "mdi:brightness-7" + } + }, + "light_mode": { + "default": "mdi:sprout", + "state": { + "flower": "mdi:flower" + } + } + }, "sensor": { "water_level": { "default": "mdi:water-percent" diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index d08b5f70a51..1397775b351 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/letpot", "integration_type": "hub", "iot_class": "cloud_push", + "loggers": ["letpot"], "quality_scale": "bronze", - "requirements": ["letpot==0.4.0"] + "requirements": ["letpot==0.6.1"] } diff --git a/homeassistant/components/letpot/select.py b/homeassistant/components/letpot/select.py new file mode 100644 index 00000000000..0a9f6b07046 --- /dev/null +++ b/homeassistant/components/letpot/select.py @@ -0,0 +1,163 @@ +"""Support for LetPot select entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import DeviceFeature, LightMode, TemperatureUnit + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription, exception_handler + +# Each change pushes a 'full' device status with the change. The library will cache +# pending changes to avoid overwriting, but try to avoid a lot of parallelism. +PARALLEL_UPDATES = 1 + + +class LightBrightnessLowHigh(StrEnum): + """Light brightness low/high model.""" + + LOW = "low" + HIGH = "high" + + +def _get_brightness_low_high_value(coordinator: LetPotDeviceCoordinator) -> str | None: + """Return brightness as low/high for a device which only has a low and high value.""" + brightness = coordinator.data.light_brightness + levels = coordinator.device_client.get_light_brightness_levels( + coordinator.device.serial_number + ) + return ( + LightBrightnessLowHigh.LOW.value + if levels[0] == brightness + else LightBrightnessLowHigh.HIGH.value + ) + + +async def _set_brightness_low_high_value( + device_client: LetPotDeviceClient, serial: str, option: str +) -> None: + """Set brightness from low/high for a device which only has a low and high value.""" + levels = device_client.get_light_brightness_levels(serial) + await device_client.set_light_brightness( + serial, levels[0] if option == LightBrightnessLowHigh.LOW.value else levels[1] + ) + + +@dataclass(frozen=True, kw_only=True) +class LetPotSelectEntityDescription(LetPotEntityDescription, SelectEntityDescription): + """Describes a LetPot select entity.""" + + value_fn: Callable[[LetPotDeviceCoordinator], str | None] + set_value_fn: Callable[[LetPotDeviceClient, str, str], Coroutine[Any, Any, None]] + + +SELECTORS: tuple[LetPotSelectEntityDescription, ...] = ( + LetPotSelectEntityDescription( + key="display_temperature_unit", + translation_key="display_temperature_unit", + options=[x.name.lower() for x in TemperatureUnit], + value_fn=( + lambda coordinator: coordinator.data.temperature_unit.name.lower() + if coordinator.data.temperature_unit is not None + else None + ), + set_value_fn=( + lambda device_client, serial, option: device_client.set_temperature_unit( + serial, TemperatureUnit[option.upper()] + ) + ), + supported_fn=( + lambda coordinator: DeviceFeature.TEMPERATURE_SET_UNIT + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features + ), + entity_category=EntityCategory.CONFIG, + ), + LetPotSelectEntityDescription( + key="light_brightness_low_high", + translation_key="light_brightness", + options=[ + LightBrightnessLowHigh.LOW.value, + LightBrightnessLowHigh.HIGH.value, + ], + value_fn=_get_brightness_low_high_value, + set_value_fn=_set_brightness_low_high_value, + supported_fn=( + lambda coordinator: DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features + ), + entity_category=EntityCategory.CONFIG, + ), + LetPotSelectEntityDescription( + key="light_mode", + translation_key="light_mode", + options=[x.name.lower() for x in LightMode], + value_fn=( + lambda coordinator: coordinator.data.light_mode.name.lower() + if coordinator.data.light_mode is not None + else None + ), + set_value_fn=( + lambda device_client, serial, option: device_client.set_light_mode( + serial, LightMode[option.upper()] + ) + ), + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot select entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotSelectEntity(coordinator, description) + for description in SELECTORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotSelectEntity(LetPotEntity, SelectEntity): + """Defines a LetPot select entity.""" + + entity_description: LetPotSelectEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotSelectEntityDescription, + ) -> None: + """Initialize LetPot select entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the selected entity option.""" + return self.entity_description.value_fn(self.coordinator) + + @exception_handler + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + return await self.entity_description.set_value_fn( + self.coordinator.device_client, + self.coordinator.device.serial_number, + option, + ) diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py index b0b113eb063..841b8720616 100644 --- a/homeassistant/components/letpot/sensor.py +++ b/homeassistant/components/letpot/sensor.py @@ -50,7 +50,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.TEMPERATURE - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSensorEntityDescription( @@ -61,7 +63,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.WATER_LEVEL - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), ) diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index cdc5a36a15f..6ebd79edf5d 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -49,6 +49,29 @@ "name": "Refill error" } }, + "select": { + "display_temperature_unit": { + "name": "Temperature unit on display", + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } + }, + "light_brightness": { + "name": "Light brightness", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "light_mode": { + "name": "Light mode", + "state": { + "flower": "Fruits/Flowers", + "vegetable": "Veggies/Herbs" + } + } + }, "sensor": { "water_level": { "name": "Water level" diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 0b00318c53b..d22bc85f116 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -25,7 +25,7 @@ class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescrip """Describes a LetPot switch entity.""" value_fn: Callable[[LetPotDeviceStatus], bool | None] - set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, bool], Coroutine[Any, Any, None]] SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( @@ -33,7 +33,9 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="alarm_sound", translation_key="alarm_sound", value_fn=lambda status: status.system_sound, - set_value_fn=lambda device_client, value: device_client.set_sound(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_sound(serial, value) + ), entity_category=EntityCategory.CONFIG, supported_fn=lambda coordinator: coordinator.data.system_sound is not None, ), @@ -41,25 +43,35 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="auto_mode", translation_key="auto_mode", value_fn=lambda status: status.water_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_water_mode( + serial, value + ) + ), entity_category=EntityCategory.CONFIG, supported_fn=( lambda coordinator: DeviceFeature.PUMP_AUTO - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSwitchEntityDescription( key="power", translation_key="power", value_fn=lambda status: status.system_on, - set_value_fn=lambda device_client, value: device_client.set_power(value), + set_value_fn=lambda device_client, serial, value: device_client.set_power( + serial, value + ), entity_category=EntityCategory.CONFIG, ), LetPotSwitchEntityDescription( key="pump_cycling", translation_key="pump_cycling", value_fn=lambda status: status.pump_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_pump_mode(value), + set_value_fn=lambda device_client, serial, value: device_client.set_pump_mode( + serial, value + ), entity_category=EntityCategory.CONFIG, ), ) @@ -104,11 +116,13 @@ class LetPotSwitchEntity(LetPotEntity, SwitchEntity): @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_value_fn(self.coordinator.device_client, True) + await self.entity_description.set_value_fn( + self.coordinator.device_client, self.coordinator.device.serial_number, True + ) @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, False + self.coordinator.device_client, self.coordinator.device.serial_number, False ) diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index bae61df6a28..87ce35f828d 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -26,7 +26,7 @@ class LetPotTimeEntityDescription(TimeEntityDescription): """Describes a LetPot time entity.""" value_fn: Callable[[LetPotDeviceStatus], time | None] - set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, time], Coroutine[Any, Any, None]] TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( @@ -34,8 +34,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_end", translation_key="light_schedule_end", value_fn=lambda status: None if status is None else status.light_schedule_end, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=None, end=value + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=None, end=value + ) ), entity_category=EntityCategory.CONFIG, ), @@ -43,8 +45,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_start", translation_key="light_schedule_start", value_fn=lambda status: None if status is None else status.light_schedule_start, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=value, end=None + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=value, end=None + ) ), entity_category=EntityCategory.CONFIG, ), @@ -89,5 +93,5 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the time.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, value + self.coordinator.device_client, self.coordinator.device.serial_number, value ) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 98a86a8d355..4810336c6e0 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + PRESET_NONE, SWING_OFF, SWING_ON, ClimateEntity, @@ -22,7 +23,6 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.temperature import display_temp from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -109,11 +109,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) self._attr_hvac_modes = [HVACMode.OFF] self._attr_hvac_mode = HVACMode.OFF - self._attr_preset_modes = [] + self._attr_preset_modes = [PRESET_NONE] + self._attr_preset_mode = PRESET_NONE self._attr_temperature_unit = ( self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS ) - self._requested_hvac_mode: str | None = None # Set up HVAC modes. for mode in self.data.hvac_modes: @@ -157,17 +157,19 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) if self.data.is_on: - hvac_mode = self._requested_hvac_mode or self.data.hvac_mode + hvac_mode = self.data.hvac_mode if hvac_mode in STR_TO_HVAC: self._attr_hvac_mode = STR_TO_HVAC.get(hvac_mode) - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE elif hvac_mode in THINQ_PRESET_MODE: + self._attr_hvac_mode = ( + HVACMode.COOL if hvac_mode == "energy_saving" else HVACMode.FAN_ONLY + ) self._attr_preset_mode = hvac_mode else: self._attr_hvac_mode = HVACMode.OFF - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE - self.reset_requested_hvac_mode() self._attr_current_humidity = self.data.humidity self._attr_current_temperature = self.data.current_temp @@ -202,10 +204,6 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.target_temperature_step, ) - def reset_requested_hvac_mode(self) -> None: - """Cancel request to set hvac mode.""" - self._requested_hvac_mode = None - async def async_turn_on(self) -> None: """Turn the entity on.""" _LOGGER.debug( @@ -226,16 +224,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): await self.async_turn_off() return + if hvac_mode == HVACMode.HEAT_COOL: + hvac_mode = HVACMode.AUTO + # If device is off, turn on first. if not self.data.is_on: await self.async_turn_on() - # When we request hvac mode while turning on the device, the previously set - # hvac mode is displayed first and then switches to the requested hvac mode. - # To prevent this, set the requested hvac mode here so that it will be set - # immediately on the next update. - self._requested_hvac_mode = HVAC_TO_STR.get(hvac_mode) - _LOGGER.debug( "[%s:%s] async_set_hvac_mode: %s", self.coordinator.device_name, @@ -244,9 +239,8 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) await self.async_call_api( self.coordinator.api.async_set_hvac_mode( - self.property_id, self._requested_hvac_mode - ), - self.reset_requested_hvac_mode, + self.property_id, HVAC_TO_STR.get(hvac_mode) + ) ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -257,6 +251,8 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.property_id, preset_mode, ) + if preset_mode == PRESET_NONE: + preset_mode = "cool" if self.preset_mode == "energy_saving" else "fan" await self.async_call_api( self.coordinator.api.async_set_hvac_mode(self.property_id, preset_mode) ) @@ -301,59 +297,50 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) ) - def _round_by_step(self, temperature: float) -> float: - """Round the value by step.""" - if ( - target_temp := display_temp( - self.coordinator.hass, - temperature, - self.coordinator.hass.config.units.temperature_unit, - self.target_temperature_step or 1, - ) - ) is not None: - return target_temp - - return temperature - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + return + + if hvac_mode == HVACMode.HEAT_COOL: + hvac_mode = HVACMode.AUTO + + # If device is off, turn on first. + if not self.data.is_on: + await self.async_turn_on() + + if hvac_mode and hvac_mode != self.hvac_mode: + await self.async_set_hvac_mode(HVACMode(hvac_mode)) + _LOGGER.debug( "[%s:%s] async_set_temperature: %s", self.coordinator.device_name, self.property_id, kwargs, ) - if hvac_mode := kwargs.get(ATTR_HVAC_MODE): - await self.async_set_hvac_mode(HVACMode(hvac_mode)) - if hvac_mode == HVACMode.OFF: - return - - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - if ( - target_temp := self._round_by_step(temperature) - ) != self.target_temperature: + if temperature := kwargs.get(ATTR_TEMPERATURE): + if self.data.step >= 1: + temperature = int(temperature) + if temperature != self.target_temperature: await self.async_call_api( self.coordinator.api.async_set_target_temperature( - self.property_id, target_temp + self.property_id, + temperature, ) ) - if (temperature_low := kwargs.get(ATTR_TARGET_TEMP_LOW)) is not None: - if ( - target_temp_low := self._round_by_step(temperature_low) - ) != self.target_temperature_low: - await self.async_call_api( - self.coordinator.api.async_set_target_temperature_low( - self.property_id, target_temp_low - ) - ) - - if (temperature_high := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None: - if ( - target_temp_high := self._round_by_step(temperature_high) - ) != self.target_temperature_high: - await self.async_call_api( - self.coordinator.api.async_set_target_temperature_high( - self.property_id, target_temp_high - ) + if (temperature_low := kwargs.get(ATTR_TARGET_TEMP_LOW)) and ( + temperature_high := kwargs.get(ATTR_TARGET_TEMP_HIGH) + ): + if self.data.step >= 1: + temperature_low = int(temperature_low) + temperature_high = int(temperature_high) + await self.async_call_api( + self.coordinator.api.async_set_target_temperature_low_high( + self.property_id, + temperature_low, + temperature_high, ) + ) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 02af1dec155..303660aef75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -219,6 +219,9 @@ "total_pollution_level": { "default": "mdi:air-filter" }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, "monitoring_enabled": { "default": "mdi:monitor-eye" }, @@ -330,9 +333,21 @@ "hop_oil_info": { "default": "mdi:information-box-outline" }, + "hop_oil_capsule_1": { + "default": "mdi:information-box-outline" + }, + "hop_oil_capsule_2": { + "default": "mdi:information-box-outline" + }, "flavor_info": { "default": "mdi:information-box-outline" }, + "flavor_capsule_1": { + "default": "mdi:information-box-outline" + }, + "flavor_capsule_2": { + "default": "mdi:information-box-outline" + }, "beer_remain": { "default": "mdi:glass-mug-variant" }, diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 754b07cb2db..44dfd251dc6 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -75,6 +75,11 @@ AIR_QUALITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { device_class=SensorDeviceClass.ENUM, translation_key=ThinQProperty.TOTAL_POLLUTION_LEVEL, ), + ThinQProperty.CO2: SensorEntityDescription( + key=ThinQProperty.CO2, + device_class=SensorDeviceClass.ENUM, + translation_key="carbon_dioxide", + ), } BATTERY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.BATTERY_PERCENT: SensorEntityDescription( @@ -175,10 +180,30 @@ RECIPE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { key=ThinQProperty.HOP_OIL_INFO, translation_key=ThinQProperty.HOP_OIL_INFO, ), + ThinQProperty.HOP_OIL_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_1, + ), + ThinQProperty.HOP_OIL_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_2, + ), ThinQProperty.FLAVOR_INFO: SensorEntityDescription( key=ThinQProperty.FLAVOR_INFO, translation_key=ThinQProperty.FLAVOR_INFO, ), + ThinQProperty.FLAVOR_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_1, + ), + ThinQProperty.FLAVOR_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_2, + ), ThinQProperty.BEER_REMAIN: SensorEntityDescription( key=ThinQProperty.BEER_REMAIN, native_unit_of_measurement=PERCENTAGE, @@ -415,6 +440,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DEHUMIDIFIER: ( JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], @@ -435,7 +461,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RECIPE_SENSOR_DESC[ThinQProperty.WORT_INFO], RECIPE_SENSOR_DESC[ThinQProperty.YEAST_INFO], RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], @@ -497,6 +527,16 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE], ), + DeviceType.VENTILATOR: ( + AIR_QUALITY_SENSOR_DESC[ThinQProperty.CO2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM10], + TEMPERATURE_SENSOR_DESC[ThinQProperty.CURRENT_TEMPERATURE], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + ), DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS, DeviceType.WASHCOMBO_MINI: WASHER_SENSORS, DeviceType.WASHER: WASHER_SENSORS, diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 38ea7b454ae..735d1dbf890 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -74,7 +74,7 @@ }, "binary_sensor": { "eco_friendly_mode": { - "name": "Eco friendly" + "name": "Eco-friendly" }, "power_save_enabled": { "name": "Power saving mode" @@ -149,7 +149,7 @@ "cliff_error": "Fall prevention sensor has an error", "clutch_error": "Clutch error", "compressor_error": "Compressor error", - "dispensing_error": "Dispensor error", + "dispensing_error": "Dispenser error", "door_close_error": "Door closed error", "door_lock_error": "Door lock error", "door_open_error": "Door open", @@ -178,7 +178,7 @@ "no_battery_error": "Robot cleaner's battery is low", "no_dust_bin_error": "Dust bin is not installed", "no_filter_error": "[%key:component::lg_thinq::entity::event::error::state_attributes::event_type::state::filter_clogging_error%]", - "out_of_balance_error": "Out of balance load", + "out_of_balance_error": "Out-of-balance load", "overfill_error": "Overfill error", "part_malfunction_error": "AIE error", "power_code_connection_error": "Power cord connection error", @@ -220,7 +220,7 @@ "error_during_cleaning": "Cleaning stopped due to an error", "error_during_washing": "An error has occurred in the washing machine", "error_has_occurred": "An error has occurred", - "frozen_is_complete": "Ice plus is done", + "frozen_is_complete": "Ice Plus is done", "homeguard_is_stopped": "Home Guard has stopped", "lack_of_water": "There is no water in the water tank", "motion_is_detected": "Photograph is sent as movement is detected during Home Guard", @@ -233,7 +233,7 @@ "styling_is_complete": "Styling is completed", "time_to_change_filter": "It is time to replace the filter", "time_to_change_water_filter": "You need to replace water filter", - "time_to_clean": "Need to selfcleaning", + "time_to_clean": "Need for self-cleaning", "time_to_clean_filter": "It is time to clean the filter", "timer_is_complete": "Timer has been completed", "washing_is_complete": "Washing is completed", @@ -333,6 +333,19 @@ "very_bad": "Poor" } }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "state": { + "invalid": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::invalid%]", + "good": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::good%]", + "normal": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "moderate": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "unhealthy": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "very_bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]", + "poor": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]" + } + }, "monitoring_enabled": { "name": "Air quality sensor", "state": { @@ -771,19 +784,57 @@ "hop_oil_info": { "name": "Hops" }, + "hop_oil_capsule_1": { + "name": "First hop", + "state": { + "cascade": "Cascade", + "chinook": "Chinook", + "goldings": "Goldings", + "fuggles": "Fuggles", + "hallertau": "Hallertau", + "citrussy": "Citrussy" + } + }, + "hop_oil_capsule_2": { + "name": "Second hop", + "state": { + "cascade": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::cascade%]", + "chinook": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::chinook%]", + "goldings": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::goldings%]", + "fuggles": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::fuggles%]", + "hallertau": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::hallertau%]", + "citrussy": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::citrussy%]" + } + }, "flavor_info": { "name": "Flavor" }, + "flavor_capsule_1": { + "name": "First flavor", + "state": { + "coriander": "Coriander", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "Orange" + } + }, + "flavor_capsule_2": { + "name": "Second flavor", + "state": { + "coriander": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::orange%]" + } + }, "beer_remain": { "name": "Recipe progress" }, "battery_level": { "name": "Battery", "state": { - "high": "Full", + "high": "[%key:common::state::full%]", "mid": "[%key:common::state::medium%]", "low": "[%key:common::state::low%]", - "warning": "Empty" + "warning": "[%key:common::state::empty%]" } }, "relative_to_start": { diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index ecc572aa006..f0505f9a4fd 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -70,6 +70,7 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { } LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} +LIFX_128ZONE_CEILING_PRODUCT_IDS = {201, 202} _LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 79ce843b339..c96f53d8f77 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -41,6 +41,7 @@ from .const import ( DEFAULT_ATTEMPTS, DOMAIN, IDENTIFY_WAVEFORM, + LIFX_128ZONE_CEILING_PRODUCT_IDS, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME, MESSAGE_RETRIES, @@ -183,6 +184,11 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """Return true if this is a matrix device.""" return bool(lifx_features(self.device)["matrix"]) + @cached_property + def is_128zone_matrix(self) -> bool: + """Return true if this is a 128-zone matrix device.""" + return bool(self.device.product in LIFX_128ZONE_CEILING_PRODUCT_IDS) + async def diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the device.""" features = lifx_features(self.device) @@ -216,6 +222,16 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): "last_result": self.device.last_hev_cycle_result, } + if features["matrix"] is True: + device_data["matrix"] = { + "effect": self.device.effect, + "chain": self.device.chain, + "chain_length": self.device.chain_length, + "tile_devices": self.device.tile_devices, + "tile_devices_count": self.device.tile_devices_count, + "tile_device_width": self.device.tile_device_width, + } + if features["infrared"] is True: device_data["infrared"] = {"brightness": self.device.infrared_brightness} @@ -291,6 +307,37 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): return calls + @callback + def _async_build_get64_update_requests(self) -> list[Callable]: + """Build one or more get64 update requests.""" + if self.device.tile_device_width == 0: + return [] + + calls: list[Callable] = [] + calls.append( + partial( + self.device.get64, + tile_index=0, + length=1, + x=0, + y=0, + width=self.device.tile_device_width, + ) + ) + if self.is_128zone_matrix: + # For 128-zone ceiling devices, we need another get64 request for the next set of zones + calls.append( + partial( + self.device.get64, + tile_index=0, + length=1, + x=0, + y=4, + width=self.device.tile_device_width, + ) + ) + return calls + async def _async_update_data(self) -> None: """Fetch all device data from the api.""" device = self.device @@ -312,9 +359,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): [ self.device.get_tile_effect, self.device.get_device_chain, - self.device.get64, ] ) + methods.extend(self._async_build_get64_update_requests()) if self.is_extended_multizone: methods.append(self.device.get_extended_color_zones) elif self.is_legacy_multizone: @@ -339,6 +386,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): if self.is_matrix or self.is_extended_multizone or self.is_legacy_multizone: self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] + if self.is_legacy_multizone and num_zones != self.get_number_of_zones(): # The number of zones has changed so we need # to update the zones again. This happens rarely. diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 3d30fcd369e..7a1b51ac8ae 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -10,6 +10,9 @@ import aiolifx_effects as aiolifx_effects_module import voluptuous as vol from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_EFFECT, ATTR_TRANSITION, LIGHT_TURN_ON_SCHEMA, @@ -234,6 +237,20 @@ class LIFXLight(LIFXEntity, LightEntity): else: fade = 0 + if ATTR_BRIGHTNESS_STEP in kwargs or ATTR_BRIGHTNESS_STEP_PCT in kwargs: + brightness = self.brightness if self.is_on and self.brightness else 0 + + if ATTR_BRIGHTNESS_STEP in kwargs: + brightness += kwargs.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + kwargs.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + + kwargs[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + # These are both False if ATTR_POWER is not set power_on = kwargs.get(ATTR_POWER, False) power_off = not kwargs.get(ATTR_POWER, True) diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 33712441157..f2e37426736 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -28,7 +28,10 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DOMAIN from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator @@ -268,7 +271,9 @@ class LIFXManager: async def service_handler(service: ServiceCall) -> None: """Apply a service, i.e. start an effect.""" - referenced = async_extract_referenced_entity_ids(self.hass, service) + referenced = async_extract_referenced_entity_ids( + self.hass, TargetSelectorData(service.data) + ) all_referenced = referenced.referenced | referenced.indirectly_referenced if all_referenced: await self.start_effect(all_referenced, service.service, **service.data) @@ -499,6 +504,5 @@ class LIFXManager: if self.entry_id_to_entity_id[entry.entry_id] in entity_ids: coordinators.append(entry.runtime_data) bulbs.append(entry.runtime_data.device) - if start_effect_func := self._effect_dispatch.get(service): await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index b93714a2cdf..3c755779846 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -52,7 +52,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.5", + "aiolifx==1.2.1", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 6218c733f4c..c0b478e895d 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -2,6 +2,9 @@ "entity_component": { "_": { "default": "mdi:lightbulb", + "state": { + "off": "mdi:lightbulb-off" + }, "state_attributes": { "effect": { "default": "mdi:circle-medium", diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py deleted file mode 100644 index a80aa99628b..00000000000 --- a/homeassistant/components/linear_garage_door/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""The Linear Garage Door integration.""" - -from __future__ import annotations - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from .const import DOMAIN -from .coordinator import LinearConfigEntry, LinearUpdateCoordinator - -PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] - - -async def async_setup_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> bool: - """Set up Linear Garage Door from a config entry.""" - - ir.async_create_issue( - hass, - DOMAIN, - DOMAIN, - breaks_in_ha_version="2025.8.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_integration", - translation_placeholders={ - "nice_go": "https://www.home-assistant.io/integrations/linear_garage_door", - "entries": "/config/integrations/integration/linear_garage_door", - }, - ) - - coordinator = LinearUpdateCoordinator(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: LinearConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_remove_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> None: - """Remove a config entry.""" - if not hass.config_entries.async_loaded_entries(DOMAIN): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - # Remove any remaining disabled or ignored entries - for _entry in hass.config_entries.async_entries(DOMAIN): - hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/linear_garage_door/config_flow.py b/homeassistant/components/linear_garage_door/config_flow.py deleted file mode 100644 index 2cfd0af6a8f..00000000000 --- a/homeassistant/components/linear_garage_door/config_flow.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Config flow for Linear Garage Door integration.""" - -from __future__ import annotations - -from collections.abc import Collection, Mapping, Sequence -import logging -from typing import Any -import uuid - -from linear_garage_door import Linear -from linear_garage_door.errors import InvalidLoginError -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = { - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): str, -} - - -async def validate_input( - hass: HomeAssistant, - data: dict[str, str], -) -> dict[str, Sequence[Collection[str]]]: - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - - hub = Linear() - - device_id = str(uuid.uuid4()) - try: - await hub.login( - data["email"], - data["password"], - device_id=device_id, - client_session=async_get_clientsession(hass), - ) - - sites = await hub.get_sites() - except InvalidLoginError as err: - raise InvalidAuth from err - finally: - await hub.close() - - return { - "email": data["email"], - "password": data["password"], - "sites": sites, - "device_id": device_id, - } - - -class LinearGarageDoorConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Linear Garage Door.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize the config flow.""" - self.data: dict[str, Sequence[Collection[str]]] = {} - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - data_schema = vol.Schema(STEP_USER_DATA_SCHEMA) - - if user_input is None: - return self.async_show_form(step_id="user", data_schema=data_schema) - - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self.data = info - - # Check if we are reauthenticating - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates={ - CONF_EMAIL: self.data["email"], - CONF_PASSWORD: self.data["password"], - }, - ) - - return await self.async_step_site() - - return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors - ) - - async def async_step_site( - self, - user_input: dict[str, Any] | None = None, - ) -> ConfigFlowResult: - """Handle the site step.""" - - if isinstance(self.data["sites"], list): - sites: list[dict[str, str]] = self.data["sites"] - - if not user_input: - return self.async_show_form( - step_id="site", - data_schema=vol.Schema( - { - vol.Required("site"): vol.In( - {site["id"]: site["name"] for site in sites} - ) - } - ), - ) - - site_id = user_input["site"] - - site_name = next(site["name"] for site in sites if site["id"] == site_id) - - await self.async_set_unique_id(site_id) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=site_name, - data={ - "site_id": site_id, - "email": self.data["email"], - "password": self.data["password"], - "device_id": self.data["device_id"], - }, - ) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Reauth in case of a password change or other error.""" - return await self.async_step_user() - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class InvalidDeviceID(HomeAssistantError): - """Error to indicate there is invalid device ID.""" diff --git a/homeassistant/components/linear_garage_door/const.py b/homeassistant/components/linear_garage_door/const.py deleted file mode 100644 index 7b3625c7c67..00000000000 --- a/homeassistant/components/linear_garage_door/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Linear Garage Door integration.""" - -DOMAIN = "linear_garage_door" diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py deleted file mode 100644 index 3844e1ae7de..00000000000 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ /dev/null @@ -1,86 +0,0 @@ -"""DataUpdateCoordinator for Linear.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import Any, cast - -from linear_garage_door import Linear -from linear_garage_door.errors import InvalidLoginError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -type LinearConfigEntry = ConfigEntry[LinearUpdateCoordinator] - - -@dataclass -class LinearDevice: - """Linear device dataclass.""" - - name: str - subdevices: dict[str, dict[str, str]] - - -class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): - """DataUpdateCoordinator for Linear.""" - - _devices: list[dict[str, Any]] | None = None - config_entry: LinearConfigEntry - - def __init__(self, hass: HomeAssistant, config_entry: LinearConfigEntry) -> None: - """Initialize DataUpdateCoordinator for Linear.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="Linear Garage Door", - update_interval=timedelta(seconds=60), - ) - self.site_id = config_entry.data["site_id"] - - async def _async_update_data(self) -> dict[str, LinearDevice]: - """Get the data for Linear.""" - - async def update_data(linear: Linear) -> dict[str, Any]: - if not self._devices: - self._devices = await linear.get_devices(self.site_id) - - data = {} - - for device in self._devices: - device_id = str(device["id"]) - state = await linear.get_device_state(device_id) - data[device_id] = LinearDevice(cast(str, device["name"]), state) - return data - - return await self.execute(update_data) - - async def execute[_T](self, func: Callable[[Linear], Awaitable[_T]]) -> _T: - """Execute an API call.""" - linear = Linear() - try: - await linear.login( - email=self.config_entry.data["email"], - password=self.config_entry.data["password"], - device_id=self.config_entry.data["device_id"], - client_session=async_get_clientsession(self.hass), - ) - except InvalidLoginError as err: - if ( - str(err) - == "Login error: Login provided is invalid, please check the email and password" - ): - raise ConfigEntryAuthFailed from err - raise ConfigEntryNotReady from err - result = await func(linear) - await linear.close() - return result diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py deleted file mode 100644 index 1f6c0999531..00000000000 --- a/homeassistant/components/linear_garage_door/cover.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Cover entity for Linear Garage Doors.""" - -from datetime import timedelta -from typing import Any - -from homeassistant.components.cover import ( - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import LinearConfigEntry -from .entity import LinearEntity - -SUPPORTED_SUBDEVICES = ["GDO"] -PARALLEL_UPDATES = 1 -SCAN_INTERVAL = timedelta(seconds=10) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: LinearConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Linear Garage Door cover.""" - coordinator = config_entry.runtime_data - - async_add_entities( - LinearCoverEntity(coordinator, device_id, device_data.name, sub_device_id) - for device_id, device_data in coordinator.data.items() - for sub_device_id in device_data.subdevices - if sub_device_id in SUPPORTED_SUBDEVICES - ) - - -class LinearCoverEntity(LinearEntity, CoverEntity): - """Representation of a Linear cover.""" - - _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - _attr_name = None - _attr_device_class = CoverDeviceClass.GARAGE - - @property - def is_closed(self) -> bool: - """Return if cover is closed.""" - return self.sub_device.get("Open_B") == "false" - - @property - def is_opened(self) -> bool: - """Return if cover is open.""" - return self.sub_device.get("Open_B") == "true" - - @property - def is_opening(self) -> bool: - """Return if cover is opening.""" - return self.sub_device.get("Opening_P") == "0" - - @property - def is_closing(self) -> bool: - """Return if cover is closing.""" - return self.sub_device.get("Opening_P") == "100" - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the garage door.""" - if self.is_closed: - return - - await self.coordinator.execute( - lambda linear: linear.operate_device( - self._device_id, self._sub_device_id, "Close" - ) - ) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the garage door.""" - if self.is_opened: - return - - await self.coordinator.execute( - lambda linear: linear.operate_device( - self._device_id, self._sub_device_id, "Open" - ) - ) diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py deleted file mode 100644 index ff5ca5639bf..00000000000 --- a/homeassistant/components/linear_garage_door/diagnostics.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Diagnostics support for Linear Garage Door.""" - -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_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant - -from .coordinator import LinearConfigEntry - -TO_REDACT = {CONF_PASSWORD, CONF_EMAIL} - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: LinearConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data - - return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "coordinator_data": { - device_id: asdict(device_data) - for device_id, device_data in coordinator.data.items() - }, - } diff --git a/homeassistant/components/linear_garage_door/entity.py b/homeassistant/components/linear_garage_door/entity.py deleted file mode 100644 index a7adf95f82e..00000000000 --- a/homeassistant/components/linear_garage_door/entity.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Base entity for Linear.""" - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import LinearDevice, LinearUpdateCoordinator - - -class LinearEntity(CoordinatorEntity[LinearUpdateCoordinator]): - """Common base for Linear entities.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: LinearUpdateCoordinator, - device_id: str, - device_name: str, - sub_device_id: str, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - - self._attr_unique_id = f"{device_id}-{sub_device_id}" - self._device_id = device_id - self._sub_device_id = sub_device_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - name=device_name, - manufacturer="Linear", - model="Garage Door Opener", - ) - - @property - def linear_device(self) -> LinearDevice: - """Return the Linear device.""" - return self.coordinator.data[self._device_id] - - @property - def sub_device(self) -> dict[str, str]: - """Return the subdevice.""" - return self.linear_device.subdevices[self._sub_device_id] diff --git a/homeassistant/components/linear_garage_door/light.py b/homeassistant/components/linear_garage_door/light.py deleted file mode 100644 index 59243817fbb..00000000000 --- a/homeassistant/components/linear_garage_door/light.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Linear garage door light.""" - -from typing import Any - -from linear_garage_door import Linear - -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import LinearConfigEntry -from .entity import LinearEntity - -SUPPORTED_SUBDEVICES = ["Light"] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: LinearConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Linear Garage Door cover.""" - coordinator = config_entry.runtime_data - data = coordinator.data - - async_add_entities( - LinearLightEntity( - device_id=device_id, - device_name=data[device_id].name, - sub_device_id=subdev, - coordinator=coordinator, - ) - for device_id in data - for subdev in data[device_id].subdevices - if subdev in SUPPORTED_SUBDEVICES - ) - - -class LinearLightEntity(LinearEntity, LightEntity): - """Light for Linear devices.""" - - _attr_color_mode = ColorMode.BRIGHTNESS - _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - _attr_translation_key = "light" - - @property - def is_on(self) -> bool: - """Return if the light is on or not.""" - return bool(self.sub_device["On_B"] == "true") - - @property - def brightness(self) -> int | None: - """Return the brightness of the light.""" - return round(int(self.sub_device["On_P"]) / 100 * 255) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the light.""" - - async def _turn_on(linear: Linear) -> None: - """Turn on the light.""" - if not kwargs: - await linear.operate_device(self._device_id, self._sub_device_id, "On") - elif ATTR_BRIGHTNESS in kwargs: - brightness = round((kwargs[ATTR_BRIGHTNESS] / 255) * 100) - await linear.operate_device( - self._device_id, self._sub_device_id, f"DimPercent:{brightness}" - ) - - await self.coordinator.execute(_turn_on) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the light.""" - - await self.coordinator.execute( - lambda linear: linear.operate_device( - self._device_id, self._sub_device_id, "Off" - ) - ) diff --git a/homeassistant/components/linear_garage_door/manifest.json b/homeassistant/components/linear_garage_door/manifest.json deleted file mode 100644 index f1eb4302cf0..00000000000 --- a/homeassistant/components/linear_garage_door/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "linear_garage_door", - "name": "Linear Garage Door", - "codeowners": ["@IceBotYT"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/linear_garage_door", - "iot_class": "cloud_polling", - "requirements": ["linear-garage-door==0.2.9"] -} diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json deleted file mode 100644 index 40ffcf22e8d..00000000000 --- a/homeassistant/components/linear_garage_door/strings.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "entity": { - "light": { - "light": { - "name": "[%key:component::light::title%]" - } - } - }, - "issues": { - "deprecated_integration": { - "title": "The Linear Garage Door integration will be removed", - "description": "The Linear Garage Door integration will be removed as it has been replaced by the [Nice G.O.]({nice_go}) integration. Please migrate to the new integration.\n\nTo resolve this issue, please remove all Linear Garage Door entries from your configuration and add the new Nice G.O. integration. [Click here to see your existing Linear Garage Door integration entries]({entries})." - } - } -} diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index 74b87f4aae9..ec85e5af97c 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -19,5 +19,5 @@ class LinkPlaySharedData: DOMAIN = "linkplay" SHARED_DATA = "shared_data" SHARED_DATA_KEY: HassKey[LinkPlaySharedData] = HassKey(SHARED_DATA) -PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SELECT] DATA_SESSION = "session" diff --git a/homeassistant/components/linkplay/icons.json b/homeassistant/components/linkplay/icons.json index c0fe86d9ac7..26f7202943f 100644 --- a/homeassistant/components/linkplay/icons.json +++ b/homeassistant/components/linkplay/icons.json @@ -4,6 +4,11 @@ "timesync": { "default": "mdi:clock" } + }, + "select": { + "audio_output_hardware_mode": { + "default": "mdi:transit-connection-horizontal" + } } }, "services": { diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 89cc498ed01..ee1cdfe67e8 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from linkplay.bridge import LinkPlayBridge from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus @@ -315,14 +315,19 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): return [] shared_data = self.hass.data[DOMAIN][SHARED_DATA] + leader_id: str | None = None + followers = [] - return [ - entity_id - for entity_id, bridge in shared_data.entity_to_bridge.items() - if bridge - in [multiroom.leader.device.uuid] - + [follower.device.uuid for follower in multiroom.followers] - ] + # find leader and followers + for ent_id, uuid in shared_data.entity_to_bridge.items(): + if uuid == multiroom.leader.device.uuid: + leader_id = ent_id + elif uuid in {f.device.uuid for f in multiroom.followers}: + followers.append(ent_id) + + if TYPE_CHECKING: + assert leader_id is not None + return [leader_id, *followers] @property def media_image_url(self) -> str | None: diff --git a/homeassistant/components/linkplay/select.py b/homeassistant/components/linkplay/select.py new file mode 100644 index 00000000000..ebf5a05512a --- /dev/null +++ b/homeassistant/components/linkplay/select.py @@ -0,0 +1,112 @@ +"""Support for LinkPlay select.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from linkplay.bridge import LinkPlayBridge, LinkPlayPlayer +from linkplay.consts import AudioOutputHwMode +from linkplay.manufacturers import MANUFACTURER_WIIM + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LinkPlayConfigEntry +from .entity import LinkPlayBaseEntity, exception_wrap + +_LOGGER = logging.getLogger(__name__) + +AUDIO_OUTPUT_HW_MODE_MAP: dict[AudioOutputHwMode, str] = { + AudioOutputHwMode.OPTICAL: "optical", + AudioOutputHwMode.LINE_OUT: "line_out", + AudioOutputHwMode.COAXIAL: "coaxial", + AudioOutputHwMode.HEADPHONES: "headphones", +} + +AUDIO_OUTPUT_HW_MODE_MAP_INV: dict[str, AudioOutputHwMode] = { + v: k for k, v in AUDIO_OUTPUT_HW_MODE_MAP.items() +} + + +async def _get_current_option(bridge: LinkPlayBridge) -> str: + """Get the current hardware mode.""" + modes = await bridge.player.get_audio_output_hw_mode() + return AUDIO_OUTPUT_HW_MODE_MAP[modes.hardware] + + +@dataclass(frozen=True, kw_only=True) +class LinkPlaySelectEntityDescription(SelectEntityDescription): + """Class describing LinkPlay select entities.""" + + set_option_fn: Callable[[LinkPlayPlayer, str], Coroutine[Any, Any, None]] + current_option_fn: Callable[[LinkPlayPlayer], Awaitable[str]] + + +SELECT_TYPES_WIIM: tuple[LinkPlaySelectEntityDescription, ...] = ( + LinkPlaySelectEntityDescription( + key="audio_output_hardware_mode", + translation_key="audio_output_hardware_mode", + current_option_fn=_get_current_option, + set_option_fn=( + lambda linkplay_bridge, + option: linkplay_bridge.player.set_audio_output_hw_mode( + AUDIO_OUTPUT_HW_MODE_MAP_INV[option] + ) + ), + options=list(AUDIO_OUTPUT_HW_MODE_MAP_INV), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LinkPlayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the LinkPlay select from config entry.""" + + # add entities + if config_entry.runtime_data.bridge.device.manufacturer == MANUFACTURER_WIIM: + async_add_entities( + LinkPlaySelect(config_entry.runtime_data.bridge, description) + for description in SELECT_TYPES_WIIM + ) + + +class LinkPlaySelect(LinkPlayBaseEntity, SelectEntity): + """Representation of LinkPlay select.""" + + entity_description: LinkPlaySelectEntityDescription + + def __init__( + self, + bridge: LinkPlayPlayer, + description: LinkPlaySelectEntityDescription, + ) -> None: + """Initialize LinkPlay select.""" + super().__init__(bridge) + self.entity_description = description + self._attr_unique_id = f"{bridge.device.uuid}-{description.key}" + + async def async_update(self) -> None: + """Get the current value from the device.""" + try: + # modes = await self.entity_description.current_option_fn(self._bridge) + self._attr_current_option = await self.entity_description.current_option_fn( + self._bridge + ) + + except ValueError as ex: + _LOGGER.debug( + "Cannot retrieve hardware mode value from device with error:, %s", ex + ) + self._attr_current_option = None + + @exception_wrap + async def async_select_option(self, option: str) -> None: + """Set the option.""" + await self.entity_description.set_option_fn(self._bridge, option) diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index 5d68754879c..7b0a6cbefe1 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -40,6 +40,17 @@ "timesync": { "name": "Sync time" } + }, + "select": { + "audio_output_hardware_mode": { + "name": "Audio output hardware mode", + "state": { + "optical": "Optical", + "line_out": "Line out", + "coaxial": "Coaxial", + "headphones": "Headphones" + } + } } }, "exceptions": { diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index c99d4794ff6..581257ab2db 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -48,6 +48,9 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[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() async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 163ad80c0a8..86a95b59b18 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -46,6 +46,12 @@ "motor_fault_short": "mdi:flash-off", "motor_ot_amps": "mdi:flash-alert" } + }, + "total_cycles": { + "default": "mdi:counter" + }, + "visits_today": { + "default": "mdi:counter" } }, "switch": { diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 81f987f8c1f..e67c681ac53 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.2.0"] + "requirements": ["pylitterbot==2024.2.3"] } diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index cdd9a1c08a5..aa7c3a451be 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -39,6 +40,7 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None + last_reset_fn: Callable[[], datetime | None] = lambda: None value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None] @@ -115,6 +117,14 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { lambda robot: status.lower() if (status := robot.status_code) else None ), ), + RobotSensorEntityDescription[LitterRobot]( + key="total_cycles", + translation_key="total_cycles", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda robot: robot.cycle_count, + ), ], LitterRobot4: [ RobotSensorEntityDescription[LitterRobot4]( @@ -171,7 +181,14 @@ PET_SENSORS: list[RobotSensorEntityDescription] = [ native_unit_of_measurement=UnitOfMass.POUNDS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda pet: pet.weight, - ) + ), + RobotSensorEntityDescription[Pet]( + key="visits_today", + translation_key="visits_today", + state_class=SensorStateClass.TOTAL, + last_reset_fn=dt_util.start_of_local_day, + value_fn=lambda pet: pet.get_visits_since(dt_util.start_of_local_day()), + ), ] @@ -217,3 +234,8 @@ class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity): if (icon := self.entity_description.icon_fn(self.state)) is not None: return icon return super().icon + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + return self.entity_description.last_reset_fn() or super().last_reset diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index ba5472918d3..35aff0f9105 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -70,7 +70,7 @@ "motor_fault_short": "Motor shorted", "motor_ot_amps": "Motor overtorqued", "motor_disconnected": "Motor disconnected", - "empty": "Empty" + "empty": "[%key:common::state::empty%]" } }, "last_seen": { @@ -118,6 +118,14 @@ "spf": "Pinch detect at startup" } }, + "total_cycles": { + "name": "Total cycles", + "unit_of_measurement": "cycles" + }, + "visits_today": { + "name": "Visits today", + "unit_of_measurement": "visits" + }, "waste_drawer": { "name": "Waste drawer" } diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 8d490dca952..1339ae7d68c 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -45,7 +45,6 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): name="Livisi devices", update_interval=timedelta(seconds=DEVICE_POLLING_DELAY), ) - self.hass = hass self.aiolivisi = aiolivisi self.websocket = Websocket(aiolivisi) self.devices: set[str] = set() diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index baebeba4f26..f95e27d31c2 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from pathlib import Path from homeassistant.config_entries import ConfigEntry @@ -11,19 +10,18 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN, STORAGE_PATH +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, STORAGE_PATH from .store import LocalCalendarStore -_LOGGER = logging.getLogger(__name__) - - PLATFORMS: list[Platform] = [Platform.CALENDAR] +type LocalCalendarConfigEntry = ConfigEntry[LocalCalendarStore] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: LocalCalendarConfigEntry +) -> bool: """Set up Local Calendar from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - if CONF_STORAGE_KEY not in entry.data: hass.config_entries.async_update_entry( entry, @@ -40,22 +38,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OSError as err: raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err - hass.data[DOMAIN][entry.entry_id] = store + entry.runtime_data = store 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: LocalCalendarConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry( + hass: HomeAssistant, entry: LocalCalendarConfigEntry +) -> None: """Handle removal of an entry.""" key = slugify(entry.data[CONF_CALENDAR_NAME]) path = Path(hass.config.path(STORAGE_PATH.format(key=key))) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 639cf5234d1..3b6d6070f5a 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -23,13 +23,13 @@ from homeassistant.components.calendar import ( CalendarEntityFeature, CalendarEvent, ) -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.util import dt as dt_util -from .const import CONF_CALENDAR_NAME, DOMAIN +from . import LocalCalendarConfigEntry +from .const import CONF_CALENDAR_NAME from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -39,11 +39,11 @@ PRODID = "-//homeassistant.io//local_calendar 1.0//EN" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LocalCalendarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the local calendar platform.""" - store = hass.data[DOMAIN][config_entry.entry_id] + store = config_entry.runtime_data ics = await store.async_load() calendar: Calendar = await hass.async_add_executor_job( IcsCalendarStream.calendar_from_ics, ics @@ -221,7 +221,7 @@ def _get_calendar_event(event: Event) -> CalendarEvent: end = start + timedelta(days=1) return CalendarEvent( - summary=event.summary, + summary=event.summary or "", start=start, end=end, description=event.description, diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py index 52c685e4929..b408b77ead9 100644 --- a/homeassistant/components/local_calendar/diagnostics.py +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -5,15 +5,14 @@ from typing import Any from ical.diagnostics import redact_ics -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .const import DOMAIN +from . import LocalCalendarConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LocalCalendarConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" payload: dict[str, Any] = { @@ -21,7 +20,7 @@ async def async_get_config_entry_diagnostics( "timezone": str(dt_util.get_default_time_zone()), "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } - store = hass.data[DOMAIN][config_entry.entry_id] + store = config_entry.runtime_data ics = await store.async_load() payload["ics"] = "\n".join(redact_ics(ics)) return payload diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 3bf00f30624..ffe4d379ce5 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==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 134cea5293b..48aa3032e73 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==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 247282309e4..1814f95d5a1 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -19,7 +19,6 @@ from aiolookin import ( ) from aiolookin.models import UDPCommandType, UDPEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -34,7 +33,7 @@ from .const import ( TYPE_TO_PLATFORM, ) from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) @@ -91,7 +90,7 @@ class LookinUDPManager: self._subscriptions = None -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool: """Set up lookin from a config entry.""" domain_data = hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] @@ -172,7 +171,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data[DOMAIN][entry.entry_id] = LookinData( + entry.runtime_data = LookinData( host=host, lookin_udp_subs=lookin_udp_subs, lookin_device=lookin_device, @@ -187,10 +186,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not hass.config_entries.async_loaded_entries(DOMAIN): manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER] @@ -199,10 +197,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove lookin config entry from a device.""" - data: LookinData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data all_identifiers: set[tuple[str, str]] = { (DOMAIN, data.lookin_device.id), *((DOMAIN, remote["UUID"]) for remote in data.devices), diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 9cef56bcf9f..cc9634ac1b6 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -20,7 +20,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_WHOLE, @@ -30,10 +29,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, TYPE_TO_PLATFORM +from .const import TYPE_TO_PLATFORM from .coordinator import LookinDataUpdateCoordinator from .entity import LookinCoordinatorEntity -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOOKIN_FAN_MODE_IDX_TO_HASS: Final = [FAN_AUTO, FAN_LOW, FAN_MIDDLE, FAN_HIGH] LOOKIN_SWING_MODE_IDX_TO_HASS: Final = [SWING_OFF, SWING_BOTH] @@ -64,11 +63,11 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the climate platform for lookin from a config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data entities = [] for remote in lookin_data.devices: @@ -92,7 +91,7 @@ async def async_setup_entry( class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): """An aircon or heat pump.""" - _attr_current_humidity: float | None = None # type: ignore[assignment] + _attr_current_humidity: float | None = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index a74cd0e4861..fd3f73120a2 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -6,13 +6,16 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time +from typing import TYPE_CHECKING -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import NEVER_TIME, POLLING_FALLBACK_SECONDS +if TYPE_CHECKING: + from .models import LookinConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -44,12 +47,12 @@ class LookinPushCoordinator: class LookinDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator to gather data for a specific lookin devices.""" - config_entry: ConfigEntry + config_entry: LookinConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, push_coordinator: LookinPushCoordinator, name: str, update_interval: timedelta | None = None, diff --git a/homeassistant/components/lookin/light.py b/homeassistant/components/lookin/light.py index d46cb96d6c0..6e467871428 100644 --- a/homeassistant/components/lookin/light.py +++ b/homeassistant/components/lookin/light.py @@ -6,25 +6,24 @@ import logging from typing import Any from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, TYPE_TO_PLATFORM +from .const import TYPE_TO_PLATFORM from .entity import LookinPowerPushRemoteEntity -from .models import LookinData +from .models import LookinConfigEntry LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform for lookin from a config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data entities = [] for remote in lookin_data.devices: diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py index a3568d9f155..16b69971370 100644 --- a/homeassistant/components/lookin/media_player.py +++ b/homeassistant/components/lookin/media_player.py @@ -12,15 +12,14 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, TYPE_TO_PLATFORM +from .const import TYPE_TO_PLATFORM from .coordinator import LookinDataUpdateCoordinator from .entity import LookinPowerPushRemoteEntity -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) @@ -43,11 +42,11 @@ _FUNCTION_NAME_TO_FEATURE = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the media_player platform for lookin from a config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data entities = [] for remote in lookin_data.devices: @@ -137,7 +136,7 @@ class LookinMedia(LookinPowerPushRemoteEntity, MediaPlayerEntity): async def async_turn_off(self) -> None: """Turn the media player off.""" await self._async_send_command(self._power_off_command) - self._attr_state = MediaPlayerState.STANDBY + self._attr_state = MediaPlayerState.OFF self.async_write_ha_state() async def async_turn_on(self) -> None: @@ -160,7 +159,5 @@ class LookinMedia(LookinPowerPushRemoteEntity, MediaPlayerEntity): state = status[0] mute = status[2] - self._attr_state = ( - MediaPlayerState.ON if state == "1" else MediaPlayerState.STANDBY - ) + self._attr_state = MediaPlayerState.ON if state == "1" else MediaPlayerState.OFF self._attr_is_volume_muted = mute == "0" diff --git a/homeassistant/components/lookin/models.py b/homeassistant/components/lookin/models.py index 3bf6ae9d862..622efb834c0 100644 --- a/homeassistant/components/lookin/models.py +++ b/homeassistant/components/lookin/models.py @@ -13,8 +13,12 @@ from aiolookin import ( Remote, ) +from homeassistant.config_entries import ConfigEntry + from .coordinator import LookinDataUpdateCoordinator +type LookinConfigEntry = ConfigEntry[LookinData] + @dataclass class LookinData: diff --git a/homeassistant/components/lookin/sensor.py b/homeassistant/components/lookin/sensor.py index 89e1ed6aa69..e53ff135b2f 100644 --- a/homeassistant/components/lookin/sensor.py +++ b/homeassistant/components/lookin/sensor.py @@ -10,14 +10,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import LookinDeviceCoordinatorEntity -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) @@ -42,11 +40,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lookin sensors from the config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data if lookin_data.lookin_device.model >= 2: async_add_entities( diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index b308e2c0f1d..94bcd2ec332 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -2,28 +2,22 @@ from __future__ import annotations -import logging import re import aiohttp from loqedAPI import loqed -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import LoqedDataCoordinator +from .coordinator import LoqedConfigEntry, LoqedDataCoordinator -PLATFORMS: list[str] = [Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LoqedConfigEntry) -> bool: """Set up loqed from a config entry.""" websession = async_get_clientsession(hass) host = entry.data["bridge_ip"] @@ -49,19 +43,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LoqedConfigEntry) -> bool: """Unload a config entry.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id] - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - await coordinator.remove_webhooks() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await entry.runtime_data.remove_webhooks() return unload_ok diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index 7b60385a759..af7667197a1 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -17,6 +17,8 @@ from .const import CONF_CLOUDHOOK_URL, DOMAIN _LOGGER = logging.getLogger(__name__) +type LoqedConfigEntry = ConfigEntry[LoqedDataCoordinator] + class BatteryMessage(TypedDict): """Properties in a battery update message.""" @@ -71,12 +73,12 @@ class StatusMessage(TypedDict): class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): """Data update coordinator for the loqed platform.""" - config_entry: ConfigEntry + config_entry: LoqedConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LoqedConfigEntry, api: loqed.LoqedAPI, lock: loqed.Lock, ) -> None: @@ -166,7 +168,9 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): await self.lock.deleteWebhook(webhook_index) -async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: +async def async_cloudhook_generate_url( + hass: HomeAssistant, entry: LoqedConfigEntry +) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: webhook_url = await cloud.async_create_cloudhook( diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 2064537df52..be44d3ef09f 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -6,12 +6,10 @@ import logging from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LoqedDataCoordinator -from .const import DOMAIN +from .coordinator import LoqedConfigEntry, LoqedDataCoordinator from .entity import LoqedEntity WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" @@ -21,13 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LoqedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Loqed lock platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([LoqedLock(coordinator)]) + async_add_entities([LoqedLock(entry.runtime_data)]) class LoqedLock(LoqedEntity, LockEntity): diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py index c28b55b4f98..a325e61d049 100644 --- a/homeassistant/components/loqed/sensor.py +++ b/homeassistant/components/loqed/sensor.py @@ -8,7 +8,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -17,8 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LoqedDataCoordinator, StatusMessage +from .coordinator import LoqedConfigEntry, LoqedDataCoordinator, StatusMessage from .entity import LoqedEntity SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( @@ -43,11 +41,11 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LoqedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Loqed lock platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities(LoqedSensor(coordinator, sensor) for sensor in SENSORS) diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index 37f0f27d2d8..bb1c80b5a58 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -6,25 +6,18 @@ the integration name. from __future__ import annotations -import logging -from typing import Any - from luftdaten import Luftdaten -from luftdaten.exceptions import LuftdatenError -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 CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_SENSOR_ID +from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LuftdatenConfigEntry) -> bool: """Set up Sensor.Community as config entry.""" # For backwards compat, set unique ID @@ -35,38 +28,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sensor_community = Luftdaten(entry.data[CONF_SENSOR_ID]) - async def async_update() -> dict[str, float | int]: - """Update sensor/binary sensor data.""" - try: - await sensor_community.get_data() - except LuftdatenError as err: - raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err - - if not sensor_community.values: - raise UpdateFailed("Did not receive sensor data from Sensor.Community") - - data: dict[str, float | int] = sensor_community.values - data.update(sensor_community.meta) - return data - - coordinator: DataUpdateCoordinator[dict[str, Any]] = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{DOMAIN}_{sensor_community.sensor_id}", - update_interval=DEFAULT_SCAN_INTERVAL, - update_method=async_update, - ) + coordinator = LuftdatenDataUpdateCoordinator(hass, entry, sensor_community) 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: LuftdatenConfigEntry) -> bool: """Unload an Sensor.Community config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/luftdaten/coordinator.py b/homeassistant/components/luftdaten/coordinator.py new file mode 100644 index 00000000000..2c311bb6409 --- /dev/null +++ b/homeassistant/components/luftdaten/coordinator.py @@ -0,0 +1,58 @@ +"""Support for Sensor.Community stations. + +Sensor.Community was previously called Luftdaten, hence the domain differs from +the integration name. +""" + +from __future__ import annotations + +import logging + +from luftdaten import Luftdaten +from luftdaten.exceptions import LuftdatenError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type LuftdatenConfigEntry = ConfigEntry[LuftdatenDataUpdateCoordinator] + + +class LuftdatenDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float | int]]): + """Data update coordinator for Sensor.Community.""" + + config_entry: LuftdatenConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LuftdatenConfigEntry, + sensor_community: Luftdaten, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{sensor_community.sensor_id}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._sensor_community = sensor_community + + async def _async_update_data(self) -> dict[str, float | int]: + """Update sensor/binary sensor data.""" + try: + await self._sensor_community.get_data() + except LuftdatenError as err: + raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err + + if not self._sensor_community.values: + raise UpdateFailed("Did not receive sensor data from Sensor.Community") + + data: dict[str, float | int] = self._sensor_community.values + data.update(self._sensor_community.meta) + return data diff --git a/homeassistant/components/luftdaten/diagnostics.py b/homeassistant/components/luftdaten/diagnostics.py index a1bbcbcadd7..3affde44387 100644 --- a/homeassistant/components/luftdaten/diagnostics.py +++ b/homeassistant/components/luftdaten/diagnostics.py @@ -5,12 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_SENSOR_ID, DOMAIN +from .const import CONF_SENSOR_ID +from .coordinator import LuftdatenConfigEntry TO_REDACT = { CONF_LATITUDE, @@ -20,10 +19,8 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LuftdatenConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[dict[str, Any]] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data return async_redact_data(coordinator.data, TO_REDACT) diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 2189386a4bb..07500f2e10c 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -23,12 +22,10 @@ from homeassistant.const import ( 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 ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN +from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -73,11 +70,11 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LuftdatenConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sensor.Community sensor based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SensorCommunitySensor( @@ -101,7 +98,7 @@ class SensorCommunitySensor(CoordinatorEntity, SensorEntity): def __init__( self, *, - coordinator: DataUpdateCoordinator, + coordinator: LuftdatenDataUpdateCoordinator, description: SensorEntityDescription, sensor_id: int, show_on_map: bool, diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index ea842f18ebd..072252cdf21 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -19,7 +19,7 @@ }, "entity": { "sensor": { - "pressure_at_sealevel": { "name": "Pressure at sealevel" } + "pressure_at_sealevel": { "name": "Pressure at sea level" } } } } diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index c0593674972..cd883a65a24 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -12,8 +12,6 @@ from homeassistant.core import HomeAssistant _LOGGER = logging.getLogger(__name__) -DOMAIN = "lupusec" - NOTIFICATION_ID = "lupusec_notification" NOTIFICATION_TITLE = "Lupusec Security Setup" @@ -24,8 +22,10 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] +type LupusecConfigEntry = ConfigEntry[lupupy.Lupusec] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: LupusecConfigEntry) -> bool: """Set up this integration using UI.""" host = entry.data[CONF_HOST] @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to connect to Lupusec device at %s", host) return False - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lupusec_system + entry.runtime_data = lupusec_system await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 03feabae0dc..69f1cfacf33 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -11,12 +11,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN +from . import LupusecConfigEntry +from .const import DOMAIN from .entity import LupusecDevice SCAN_INTERVAL = timedelta(seconds=2) @@ -24,11 +24,11 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LupusecConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data alarm = await hass.async_add_executor_job(data.get_alarm) diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index bcd21adc1aa..356ec9ab99b 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -12,11 +12,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN +from . import LupusecConfigEntry from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) @@ -26,12 +25,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LupusecConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a binary sensors for a Lupusec device.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index a70df90f8e7..346d1a35703 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -9,11 +9,10 @@ from typing import Any import lupupy.constants as CONST 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 DOMAIN +from . import LupusecConfigEntry from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) @@ -21,12 +20,12 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LupusecConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lupusec switch devices.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data device_types = CONST.TYPE_SWITCH diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 8ec9785cef2..c221b03a891 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from aiolyric import Lyric -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -19,14 +18,14 @@ from .api import ( OAuth2SessionLyric, ) from .const import DOMAIN -from .coordinator import LyricDataUpdateCoordinator +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool: """Set up Honeywell Lyric from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -53,17 +52,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe 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: LyricConfigEntry) -> 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/lyric/climate.py b/homeassistant/components/lyric/climate.py index 4aeccf991d5..e71c81774af 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -24,7 +24,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -38,7 +37,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ( - DOMAIN, LYRIC_EXCEPTIONS, PRESET_HOLD_UNTIL, PRESET_NO_HOLD, @@ -46,7 +44,7 @@ from .const import ( PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) -from .coordinator import LyricDataUpdateCoordinator +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator from .entity import LyricDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -121,11 +119,11 @@ SCHEMA_HOLD_TIME: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LyricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric climate platform based on a config entry.""" - coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index c177e233516..b9b36e56133 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -20,16 +20,18 @@ from .api import OAuth2SessionLyric _LOGGER = logging.getLogger(__name__) +type LyricConfigEntry = ConfigEntry[LyricDataUpdateCoordinator] + class LyricDataUpdateCoordinator(DataUpdateCoordinator[Lyric]): """Data update coordinator for Honeywell Lyric.""" - config_entry: ConfigEntry + config_entry: LyricConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LyricConfigEntry, oauth_session: OAuth2SessionLyric, lyric: Lyric, ) -> None: diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index ffebb8056cd..f0a8d572353 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,14 +23,13 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( - DOMAIN, PRESET_HOLD_UNTIL, PRESET_NO_HOLD, PRESET_PERMANENT_HOLD, PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) -from .coordinator import LyricDataUpdateCoordinator +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator from .entity import LyricAccessoryEntity, LyricDeviceEntity LYRIC_SETPOINT_STATUS_NAMES = { @@ -159,11 +157,11 @@ def get_datetime_from_future_time(time_str: str) -> datetime: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LyricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric sensor platform based on a config entry.""" - coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LyricSensor( diff --git a/homeassistant/components/madvr/manifest.json b/homeassistant/components/madvr/manifest.json index 0ac906fdbef..e45a4c60f30 100644 --- a/homeassistant/components/madvr/manifest.json +++ b/homeassistant/components/madvr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/madvr", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-madvr2==1.6.32"] + "requirements": ["py-madvr2==1.6.40"] } diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 17b8614a2e9..b6e0d863471 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -8,12 +8,11 @@ from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_NAME, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -22,7 +21,7 @@ from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client -PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -53,26 +52,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> entry.runtime_data = MastodonData(client, instance, account, coordinator) - await discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - {CONF_NAME: entry.title, "client": client}, - {}, - ) - - await hass.config_entries.async_forward_entry_setups( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 2efda329467..8a77eebcf7a 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,7 +12,6 @@ DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" ATTR_CONTENT_WARNING = "content_warning" diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index d7b21ad3a0c..4632ee1eff6 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "requirements": ["Mastodon.py==2.0.1"] + "quality_scale": "bronze", + "requirements": ["Mastodon.py==2.1.1"] } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py deleted file mode 100644 index 149ef1f6a48..00000000000 --- a/homeassistant/components/mastodon/notify.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Mastodon platform for notify component.""" - -from __future__ import annotations - -from typing import Any, cast - -from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError, MediaAttachment -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_DATA, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, - BaseNotificationService, -) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import ( - ATTR_CONTENT_WARNING, - ATTR_MEDIA_WARNING, - CONF_BASE_URL, - DEFAULT_URL, - DOMAIN, -) -from .utils import get_media_type - -ATTR_MEDIA = "media" -ATTR_TARGET = "target" - -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_BASE_URL, default=DEFAULT_URL): cv.string, - } -) - -INTEGRATION_TITLE = "Mastodon" - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> MastodonNotificationService | None: - """Get the Mastodon notification service.""" - if discovery_info is None: - return None - - client = cast(Mastodon, discovery_info.get("client")) - - return MastodonNotificationService(hass, client) - - -class MastodonNotificationService(BaseNotificationService): - """Implement the notification service for Mastodon.""" - - def __init__( - self, - hass: HomeAssistant, - client: Mastodon, - ) -> None: - """Initialize the service.""" - - self.client = client - - def send_message(self, message: str = "", **kwargs: Any) -> None: - """Toot a message, with media perhaps.""" - - ir.create_issue( - self.hass, - DOMAIN, - "deprecated_notify_action_mastodon", - breaks_in_ha_version="2025.9.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_notify_action", - ) - - target = None - if (target_list := kwargs.get(ATTR_TARGET)) is not None: - target = cast(list[str], target_list)[0] - - data = kwargs.get(ATTR_DATA) - - media = None - mediadata = None - sensitive = False - content_warning = None - - if data: - media = data.get(ATTR_MEDIA) - if media: - if not self.hass.config.is_allowed_path(media): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="not_whitelisted_directory", - translation_placeholders={"media": media}, - ) - mediadata = self._upload_media(media) - - sensitive = data.get(ATTR_MEDIA_WARNING) - content_warning = data.get(ATTR_CONTENT_WARNING) - - if mediadata: - try: - self.client.status_post( - message, - visibility=target, - spoiler_text=content_warning, - media_ids=mediadata.id, - sensitive=sensitive, - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_send_message", - ) from err - - else: - try: - self.client.status_post( - message, visibility=target, spoiler_text=content_warning - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_send_message", - ) from err - - def _upload_media(self, media_path: Any = None) -> MediaAttachment: - """Upload media.""" - with open(media_path, "rb"): - media_type = get_media_type(media_path) - try: - mediadata: MediaAttachment = self.client.media_post( - media_path, mime_type=media_type - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_upload_image", - translation_placeholders={"media_path": media_path}, - ) from err - - return mediadata diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index f07f7e0a8ad..ff3d4ad3db0 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -6,10 +6,7 @@ rules: common-modules: done config-flow-test-coverage: done config-flow: done - dependency-transparency: - status: todo - comment: | - Mastodon.py does not have CI build/publish. + dependency-transparency: done docs-actions: done docs-high-level-description: done docs-installation-instructions: done @@ -26,10 +23,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - Awaiting legacy Notify deprecation. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -39,19 +33,12 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: | - Awaiting legacy Notify deprecation. + parallel-updates: done reauthentication-flow: status: todo comment: | Waiting to move to oAuth. - test-coverage: - status: todo - comment: | - Awaiting legacy Notify deprecation. - + test-coverage: done # Gold devices: done diagnostics: done diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 68e95e726a1..0815fee34ec 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -9,11 +9,11 @@ from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 9e6cf6db6bf..c37f9b2e941 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -42,12 +42,6 @@ "message": "{media} is not a whitelisted directory." } }, - "issues": { - "deprecated_notify_action": { - "title": "Deprecated Notify action used for Mastodon", - "description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead." - } - }, "entity": { "sensor": { "followers": { diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 85f08bb4d87..f523de71f6a 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -45,7 +45,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType, load_json_object from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML -from .services import register_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: config[CONF_COMMANDS], ) - register_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 6cab2c39c97..103c410855c 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==11.2.1"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.3.0"] } diff --git a/homeassistant/components/matrix/services.py b/homeassistant/components/matrix/services.py index edd312348d6..f89a9e7b7fc 100644 --- a/homeassistant/components/matrix/services.py +++ b/homeassistant/components/matrix/services.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( @@ -50,7 +50,8 @@ async def _handle_send_message(call: ServiceCall) -> None: await matrix_bot.handle_send_message(call) -def register_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the Matrix bot component.""" hass.services.async_register( diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 95efe46309c..3ce0cc68012 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -54,7 +54,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self.entity_description.measurement_to_ha: + elif value_convert := self.entity_description.device_to_ha: value = value_convert(value) if TYPE_CHECKING: value = cast(bool | None, value) @@ -70,7 +70,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="HueMotionSensor", device_class=BinarySensorDeviceClass.MOTION, - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, + device_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), @@ -83,7 +83,7 @@ DISCOVERY_SCHEMAS = [ key="OccupancySensor", device_class=BinarySensorDeviceClass.OCCUPANCY, # The first bit = if occupied - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, + device_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), @@ -94,7 +94,7 @@ DISCOVERY_SCHEMAS = [ key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - measurement_to_ha=lambda x: x + device_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevelEnum.kOk, ), entity_class=MatterBinarySensor, @@ -109,7 +109,7 @@ DISCOVERY_SCHEMAS = [ key="ContactSensor", device_class=BinarySensorDeviceClass.DOOR, # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, + device_to_ha=lambda x: not x, ), entity_class=MatterBinarySensor, required_attributes=(clusters.BooleanState.Attributes.StateValue,), @@ -153,7 +153,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="LockDoorStateSensor", device_class=BinarySensorDeviceClass.DOOR, - measurement_to_ha={ + device_to_ha={ clusters.DoorLock.Enums.DoorStateEnum.kDoorOpen: True, clusters.DoorLock.Enums.DoorStateEnum.kDoorJammed: True, clusters.DoorLock.Enums.DoorStateEnum.kDoorForcedOpen: True, @@ -168,7 +168,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmDeviceMutedSensor", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.SmokeCoAlarm.Enums.MuteStateEnum.kMuted ), translation_key="muted", @@ -181,7 +181,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmEndfOfServiceSensor", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.SmokeCoAlarm.Enums.EndOfServiceEnum.kExpired ), translation_key="end_of_service", @@ -195,7 +195,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmBatteryAlertSensor", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), translation_key="battery_alert", @@ -232,7 +232,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmSmokeStateSensor", device_class=BinarySensorDeviceClass.SMOKE, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), ), @@ -244,7 +244,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmInterconnectSmokeAlarmSensor", device_class=BinarySensorDeviceClass.SMOKE, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), translation_key="interconnected_smoke_alarm", @@ -257,7 +257,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmInterconnectCOAlarmSensor", device_class=BinarySensorDeviceClass.CO, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), translation_key="interconnected_co_alarm", @@ -271,7 +271,7 @@ DISCOVERY_SCHEMAS = [ key="EnergyEvseChargingStatusSensor", translation_key="evse_charging_status", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - measurement_to_ha={ + device_to_ha={ clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False, clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False, @@ -291,7 +291,7 @@ DISCOVERY_SCHEMAS = [ key="EnergyEvsePlugStateSensor", translation_key="evse_plug_state", device_class=BinarySensorDeviceClass.PLUG, - measurement_to_ha={ + device_to_ha={ clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True, clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True, @@ -309,9 +309,9 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="EnergyEvseSupplyStateSensor", - translation_key="evse_supply_charging_state", + translation_key="evse_supply_state", device_class=BinarySensorDeviceClass.RUNNING, - measurement_to_ha={ + device_to_ha={ clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, @@ -327,7 +327,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="WaterHeaterManagementBoostStateSensor", translation_key="boost_state", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive ), ), @@ -342,7 +342,7 @@ DISCOVERY_SCHEMAS = [ device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, # DeviceFault or SupplyFault bit enabled - measurement_to_ha={ + device_to_ha={ clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True, clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True, clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False, @@ -366,7 +366,7 @@ DISCOVERY_SCHEMAS = [ key="PumpStatusRunning", translation_key="pump_running", device_class=BinarySensorDeviceClass.RUNNING, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning ), @@ -384,7 +384,7 @@ DISCOVERY_SCHEMAS = [ translation_key="dishwasher_alarm_inflow", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError ), ), @@ -399,7 +399,7 @@ DISCOVERY_SCHEMAS = [ translation_key="dishwasher_alarm_door", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError ), ), diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 6a0a5fc5b1d..f75c5063c06 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -143,4 +143,16 @@ DISCOVERY_SCHEMAS = [ value_contains=clusters.ActivatedCarbonFilterMonitoring.Commands.ResetCondition.command_id, allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="SmokeCoAlarmSelfTestRequest", + translation_key="self_test_request", + entity_category=EntityCategory.DIAGNOSTIC, + command=clusters.SmokeCoAlarm.Commands.SelfTestRequest, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,), + value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id, + ), ] diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 2e2d4390b30..7bef7ea1853 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -31,8 +31,14 @@ OPERATIONAL_STATUS_MASK = 0b11 # map Matter window cover types to HA device class TYPE_MAP = { + clusters.WindowCovering.Enums.Type.kRollerShade: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShade2Motor: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShadeExterior: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShadeExterior2Motor: CoverDeviceClass.SHADE, clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING, clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN, + clusters.WindowCovering.Enums.Type.kTiltBlindTiltOnly: CoverDeviceClass.BLIND, + clusters.WindowCovering.Enums.Type.kTiltBlindLiftAndTilt: CoverDeviceClass.BLIND, } diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index fded57d34f5..028feab9c88 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -59,8 +59,8 @@ class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" # convert the value from the primary attribute to the value used by HA - measurement_to_ha: Callable[[Any], Any] | None = None - ha_to_native_value: Callable[[Any], Any] | None = None + device_to_ha: Callable[[Any], Any] | None = None + ha_to_device: Callable[[Any], Any] | None = None command_timeout: int | None = None diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 32f822414aa..dc1fbc25181 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -17,6 +17,9 @@ }, "stop": { "default": "mdi:stop" + }, + "self_test_request": { + "default": "mdi:refresh-auto" } }, "fan": { @@ -40,6 +43,9 @@ "laundry_washer_spin_speed": { "default": "mdi:reload" }, + "power_level": { + "default": "mdi:power-settings" + }, "temperature_level": { "default": "mdi:thermometer" } @@ -51,6 +57,15 @@ "current_phase": { "default": "mdi:state-machine" }, + "eve_weather_trend": { + "default": "mdi:weather", + "state": { + "sunny": "mdi:weather-sunny", + "cloudy": "mdi:weather-cloudy", + "rainy": "mdi:weather-rainy", + "stormy": "mdi:weather-windy" + } + }, "air_quality": { "default": "mdi:air-filter" }, @@ -96,6 +111,9 @@ "esa_opt_out_state": { "default": "mdi:home-lightning-bolt" }, + "esa_state": { + "default": "mdi:home-lightning-bolt" + }, "evse_state": { "default": "mdi:ev-station" }, @@ -115,6 +133,11 @@ "default": "mdi:pump" } }, + "number": { + "cook_time": { + "default": "mdi:microwave" + } + }, "switch": { "child_lock": { "default": "mdi:lock", diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index c61fd0879fa..a86938730c9 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models import device_types from homeassistant.components.light import ( @@ -241,7 +242,7 @@ class MatterLight(MatterEntity, LightEntity): return int(color_temp) - def _get_brightness(self) -> int: + def _get_brightness(self) -> int | None: """Get brightness from matter.""" level_control = self._endpoint.get_cluster(clusters.LevelControl) @@ -255,6 +256,10 @@ class MatterLight(MatterEntity, LightEntity): self.entity_id, ) + if level_control.currentLevel is NullValue: + # currentLevel is a nullable value. + return None + return round( renormalize( level_control.currentLevel, diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 48f0bfa2e67..b79113d422e 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,6 +7,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==7.0.0"], + "requirements": ["python-matter-server==8.1.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4b469fa85e4..d2184891dc1 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -2,9 +2,13 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand +from matter_server.client.models import device_types from matter_server.common import custom_clusters from homeassistant.components.number import ( @@ -15,6 +19,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + PERCENTAGE, EntityCategory, Platform, UnitOfLength, @@ -44,6 +49,27 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip """Describe Matter Number Input entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterRangeNumberEntityDescription( + NumberEntityDescription, MatterEntityDescription +): + """Describe Matter Number Input entities with min and max values.""" + + ha_to_device: Callable[[Any], Any] = lambda x: x + + # attribute descriptors to get the min and max value + min_attribute: type[ClusterAttributeDescriptor] | None = None + max_attribute: type[ClusterAttributeDescriptor] + + # Functions to format the min and max values for display or conversion + format_min_value: Callable[[float], float] = lambda x: x + format_max_value: Callable[[float], float] = lambda x: x + + # command: a custom callback to create the command to send to the device + # the callback's argument will be the index of the selected list value + command: Callable[[int], ClusterCommand] + + class MatterNumber(MatterEntity, NumberEntity): """Representation of a Matter Attribute as a Number entity.""" @@ -52,7 +78,7 @@ class MatterNumber(MatterEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" sendvalue = int(value) - if value_convert := self.entity_description.ha_to_native_value: + if value_convert := self.entity_description.ha_to_device: sendvalue = value_convert(value) await self.write_attribute( value=sendvalue, @@ -62,7 +88,73 @@ class MatterNumber(MatterEntity, NumberEntity): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: + value = value_convert(value) + self._attr_native_value = value + + +class MatterRangeNumber(MatterEntity, NumberEntity): + """Representation of a Matter Attribute as a Number entity with min and max values.""" + + entity_description: MatterRangeNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + send_value = self.entity_description.ha_to_device(value) + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.command(send_value), + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # get the value from the primary attribute and convert it to the HA value if needed + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.device_to_ha: + value = value_convert(value) + self._attr_native_value = value + + # min case 1: get min from the attribute and convert it + if self.entity_description.min_attribute: + min_value = self.get_matter_attribute_value( + self.entity_description.min_attribute + ) + min_convert = self.entity_description.format_min_value + self._attr_native_min_value = min_convert(min_value) + # min case 2: get the min from entity_description + elif self.entity_description.native_min_value is not None: + self._attr_native_min_value = self.entity_description.native_min_value + + # get max from the attribute and convert it + max_value = self.get_matter_attribute_value( + self.entity_description.max_attribute + ) + max_convert = self.entity_description.format_max_value + self._attr_native_max_value = max_convert(max_value) + + +class MatterLevelControlNumber(MatterEntity, NumberEntity): + """Representation of a Matter Attribute as a Number entity.""" + + entity_description: MatterNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Set level value.""" + send_value = int(value) + if value_convert := self.entity_description.ha_to_device: + send_value = value_convert(value) + await self.send_device_command( + clusters.LevelControl.Commands.MoveToLevel( + level=send_value, + ) + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value @@ -79,8 +171,8 @@ DISCOVERY_SCHEMAS = [ native_min_value=0, mode=NumberMode.BOX, # use 255 to indicate that the value should revert to the default - measurement_to_ha=lambda x: 255 if x is None else x, - ha_to_native_value=lambda x: None if x == 255 else int(x), + 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, ), @@ -97,8 +189,8 @@ DISCOVERY_SCHEMAS = [ translation_key="on_transition_time", native_max_value=65534, native_min_value=0, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), native_step=0.1, native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -116,8 +208,8 @@ DISCOVERY_SCHEMAS = [ translation_key="off_transition_time", native_max_value=65534, native_min_value=0, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), native_step=0.1, native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -135,8 +227,8 @@ DISCOVERY_SCHEMAS = [ translation_key="on_off_transition_time", native_max_value=65534, native_min_value=0, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), native_step=0.1, native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -173,8 +265,8 @@ DISCOVERY_SCHEMAS = [ native_min_value=-50, native_step=0.5, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), mode=NumberMode.BOX, ), entity_class=MatterNumber, @@ -183,6 +275,28 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="pump_setpoint", + native_unit_of_measurement=PERCENTAGE, + translation_key="pump_setpoint", + native_max_value=100, + native_min_value=0.5, + native_step=0.5, + device_to_ha=( + lambda x: None + if x is None + else min(x, 200) / 2 # Matter range (1-200, capped at 200) + ), + ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0% + mode=NumberMode.SLIDER, + ), + entity_class=MatterLevelControlNumber, + required_attributes=(clusters.LevelControl.Attributes.CurrentLevel,), + device_type=(device_types.Pump,), + allow_multi=True, + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( @@ -199,6 +313,27 @@ DISCOVERY_SCHEMAS = [ clusters.OccupancySensing.Attributes.PIROccupiedToUnoccupiedDelay, ), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="MicrowaveOvenControlCookTime", + translation_key="cook_time", + device_class=NumberDeviceClass.DURATION, + command=lambda value: clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + cookTime=int(value) + ), + native_min_value=1, # 1 second minimum cook time + native_step=1, # 1 second + native_unit_of_measurement=UnitOfTime.SECONDS, + max_attribute=clusters.MicrowaveOvenControl.Attributes.MaxCookTime, + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.MicrowaveOvenControl.Attributes.CookTime, + clusters.MicrowaveOvenControl.Attributes.MaxCookTime, + ), + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( @@ -213,4 +348,61 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterNumber, required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="TemperatureControlTemperatureSetpoint", + name=None, + translation_key="temperature_setpoint", + command=lambda value: clusters.TemperatureControl.Commands.SetTemperature( + targetTemperature=value + ), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_to_ha=lambda x: None if x is None else x / 100, + ha_to_device=lambda x: round(x * 100), + format_min_value=lambda x: x / 100, + format_max_value=lambda x: x / 100, + min_attribute=clusters.TemperatureControl.Attributes.MinTemperature, + max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature, + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.TemperatureControl.Attributes.TemperatureSetpoint, + clusters.TemperatureControl.Attributes.MinTemperature, + clusters.TemperatureControl.Attributes.MaxTemperature, + ), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="InovelliLEDIndicatorIntensityOff", + entity_category=EntityCategory.CONFIG, + translation_key="led_indicator_intensity_off", + native_max_value=75, + native_min_value=0, + native_step=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOff, + ), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="InovelliLEDIndicatorIntensityOn", + entity_category=EntityCategory.CONFIG, + translation_key="led_indicator_intensity_on", + native_max_value=75, + native_min_value=0, + native_step=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOn, + ), + ), ] diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index ac1bc2d1f8f..5d7a5363da0 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -71,8 +71,8 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip class MatterMapSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterMapSelectEntityDescription.""" - measurement_to_ha: Callable[[int], str | None] - ha_to_native_value: Callable[[str], int | None] + device_to_ha: Callable[[int], str | None] + ha_to_device: Callable[[str], int | None] # list attribute: the attribute descriptor to get the list of values (= list of integers) list_attribute: type[ClusterAttributeDescriptor] @@ -97,7 +97,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected mode.""" - value_convert = self.entity_description.ha_to_native_value + value_convert = self.entity_description.ha_to_device if TYPE_CHECKING: assert value_convert is not None await self.write_attribute( @@ -109,7 +109,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): """Update from device.""" value: Nullable | int | None value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - value_convert = self.entity_description.measurement_to_ha + value_convert = self.entity_description.device_to_ha if TYPE_CHECKING: assert value_convert is not None self._attr_current_option = value_convert(value) @@ -132,7 +132,7 @@ class MatterMapSelectEntity(MatterAttributeSelectEntity): self._attr_options = [ mapped_value for value in available_values - if (mapped_value := self.entity_description.measurement_to_ha(value)) + if (mapped_value := self.entity_description.device_to_ha(value)) ] # use base implementation from MatterAttributeSelectEntity to set the current option super()._update_from_device() @@ -197,10 +197,14 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - list_values = cast( - list[str], - self.get_matter_attribute_value(self.entity_description.list_attribute), + list_values_raw = self.get_matter_attribute_value( + self.entity_description.list_attribute ) + if TYPE_CHECKING: + assert list_values_raw is not None + + # Accept both list[str] and list[int], convert to str + list_values = [str(v) for v in list_values_raw] self._attr_options = list_values current_option_idx: int = self.get_matter_attribute_value( self._entity_info.primary_attribute @@ -333,13 +337,13 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="startup_on_off", options=["on", "off", "toggle", "previous"], - measurement_to_ha={ + device_to_ha={ 0: "off", 1: "on", 2: "toggle", None: "previous", }.get, - ha_to_native_value={ + ha_to_device={ "off": 0, "on": 1, "toggle": 2, @@ -358,12 +362,12 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="sensitivity_level", options=["high", "standard", "low"], - measurement_to_ha={ + device_to_ha={ 0: "high", 1: "standard", 2: "low", }.get, - ha_to_native_value={ + ha_to_device={ "high": 0, "standard": 1, "low": 2, @@ -379,11 +383,11 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="temperature_display_mode", options=["Celsius", "Fahrenheit"], - measurement_to_ha={ + device_to_ha={ 0: "Celsius", 1: "Fahrenheit", }.get, - ha_to_native_value={ + ha_to_device={ "Celsius": 0, "Fahrenheit": 1, }.get, @@ -432,8 +436,8 @@ DISCOVERY_SCHEMAS = [ key="MatterLaundryWasherNumberOfRinses", translation_key="laundry_washer_number_of_rinses", list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses, - measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, - ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, + device_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, + ha_to_device=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, ), entity_class=MatterMapSelectEntity, required_attributes=( @@ -443,6 +447,24 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported rinses list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="MicrowaveOvenControlSelectedWattIndex", + translation_key="power_level", + command=lambda selected_index: clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + wattSettingIndex=selected_index + ), + list_attribute=clusters.MicrowaveOvenControl.Attributes.SupportedWatts, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.MicrowaveOvenControl.Attributes.SelectedWattIndex, + clusters.MicrowaveOvenControl.Attributes.SupportedWatts, + ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( @@ -450,13 +472,13 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="door_lock_sound_volume", options=["silent", "low", "medium", "high"], - measurement_to_ha={ + device_to_ha={ 0: "silent", 1: "low", 3: "medium", 2: "high", }.get, - ha_to_native_value={ + ha_to_device={ "silent": 0, "low": 1, "medium": 3, @@ -472,8 +494,8 @@ DISCOVERY_SCHEMAS = [ key="PumpConfigurationAndControlOperationMode", translation_key="pump_operation_mode", options=list(PUMP_OPERATION_MODE_MAP.values()), - measurement_to_ha=PUMP_OPERATION_MODE_MAP.get, - ha_to_native_value=PUMP_OPERATION_MODE_MAP_REVERSE.get, + device_to_ha=PUMP_OPERATION_MODE_MAP.get, + ha_to_device=PUMP_OPERATION_MODE_MAP_REVERSE.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=( diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 0b4d3cc3330..18bd7f84da3 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -2,8 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime +from dataclasses import dataclass, field +from datetime import datetime, timedelta from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters @@ -32,11 +32,13 @@ from homeassistant.const import ( REVOLUTIONS_PER_MINUTE, EntityCategory, Platform, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfPressure, + UnitOfReactivePower, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -44,7 +46,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -68,12 +70,25 @@ CONTAMINATION_STATE_MAP = { clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kCritical: "critical", } +EVE_CLUSTER_WEATHER_MAP = { + # enum with known Weather state values which we can translate + 1: "sunny", + 3: "cloudy", + 6: "rainy", + 14: "stormy", +} + OPERATIONAL_STATE_MAP = { # enum with known Operation state values which we can translate clusters.OperationalState.Enums.OperationalStateEnum.kStopped: "stopped", clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running", clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused", clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", +} + +RVC_OPERATIONAL_STATE_MAP = { + # enum with known Operation state values which we can translate + **OPERATIONAL_STATE_MAP, clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger", clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging", clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", @@ -171,6 +186,10 @@ class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescriptio state_list_attribute: type[ClusterAttributeDescriptor] = ( clusters.OperationalState.Attributes.OperationalStateList ) + state_attribute: type[ClusterAttributeDescriptor] = ( + clusters.OperationalState.Attributes.OperationalState + ) + state_map: dict[int, str] = field(default_factory=lambda: OPERATIONAL_STATE_MAP) class MatterSensor(MatterEntity, SensorEntity): @@ -185,7 +204,7 @@ class MatterSensor(MatterEntity, SensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self.entity_description.measurement_to_ha: + elif value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value @@ -245,15 +264,15 @@ class MatterOperationalStateSensor(MatterSensor): for state in operational_state_list: # prefer translateable (known) state from mapping, # fallback to the raw state label as given by the device/manufacturer - states_map[state.operationalStateID] = OPERATIONAL_STATE_MAP.get( - state.operationalStateID, slugify(state.operationalStateLabel) + states_map[state.operationalStateID] = ( + self.entity_description.state_map.get( + state.operationalStateID, slugify(state.operationalStateLabel) + ) ) self.states_map = states_map self._attr_options = list(states_map.values()) self._attr_native_value = states_map.get( - self.get_matter_attribute_value( - clusters.OperationalState.Attributes.OperationalState - ) + self.get_matter_attribute_value(self.entity_description.state_attribute) ) @@ -287,7 +306,7 @@ DISCOVERY_SCHEMAS = [ key="TemperatureSensor", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - measurement_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / 100, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -299,7 +318,7 @@ DISCOVERY_SCHEMAS = [ key="PressureSensor", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -311,7 +330,7 @@ DISCOVERY_SCHEMAS = [ key="FlowSensor", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -323,7 +342,7 @@ DISCOVERY_SCHEMAS = [ key="HumiditySensor", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, - measurement_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / 100, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -337,7 +356,7 @@ DISCOVERY_SCHEMAS = [ key="LightSensor", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + device_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -351,7 +370,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, # value has double precision - measurement_to_ha=lambda x: int(x / 2), + device_to_ha=lambda x: int(x / 2), state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -393,7 +412,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=[state for state in CHARGE_STATE_MAP.values() if state is not None], - measurement_to_ha=CHARGE_STATE_MAP.get, + device_to_ha=CHARGE_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeState,), @@ -506,6 +525,19 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Pressure,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveWeatherWeatherTrend", + translation_key="eve_weather_trend", + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + options=[x for x in EVE_CLUSTER_WEATHER_MAP.values() if x is not None], + device_to_ha=EVE_CLUSTER_WEATHER_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(EveCluster.Attributes.WeatherTrend,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -580,7 +612,7 @@ DISCOVERY_SCHEMAS = [ state_class=None, # convert to set first to remove the duplicate unknown value options=[x for x in AIR_QUALITY_MAP.values() if x is not None], - measurement_to_ha=lambda x: AIR_QUALITY_MAP[x], + device_to_ha=lambda x: AIR_QUALITY_MAP[x], ), entity_class=MatterSensor, required_attributes=(clusters.AirQuality.Attributes.AirQuality,), @@ -659,7 +691,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 1000, + device_to_ha=lambda x: x / 1000, ), entity_class=MatterSensor, required_attributes=( @@ -676,7 +708,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, - measurement_to_ha=lambda x: x / 1000, + device_to_ha=lambda x: x / 1000, ), entity_class=MatterSensor, required_attributes=( @@ -693,7 +725,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Watt,), @@ -722,7 +754,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Voltage,), @@ -773,10 +805,43 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalPowerMeasurement.Attributes.ActivePower, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementApparentPower", + device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfApparentPower.MILLIVOLT_AMPERE, + suggested_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ApparentPower, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementReactivePower", + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + suggested_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ReactivePower, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementVoltage", + translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, @@ -787,10 +852,45 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementRMSVoltage", + translation_key="rms_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.RMSVoltage, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementApparentCurrent", + translation_key="apparent_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ApparentCurrent, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementActiveCurrent", + translation_key="active_current", device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, @@ -803,6 +903,40 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementReactiveCurrent", + translation_key="reactive_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ReactiveCurrent, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementRMSCurrent", + translation_key="rms_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.RMSCurrent, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -814,7 +948,7 @@ DISCOVERY_SCHEMAS = [ suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) - measurement_to_ha=lambda x: x.energy, + device_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, required_attributes=( @@ -833,7 +967,7 @@ DISCOVERY_SCHEMAS = [ suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) - measurement_to_ha=lambda x: x.energy, + device_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, required_attributes=( @@ -901,7 +1035,7 @@ DISCOVERY_SCHEMAS = [ translation_key="contamination_state", device_class=SensorDeviceClass.ENUM, options=list(CONTAMINATION_STATE_MAP.values()), - measurement_to_ha=CONTAMINATION_STATE_MAP.get, + device_to_ha=CONTAMINATION_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.ContaminationState,), @@ -913,7 +1047,7 @@ DISCOVERY_SCHEMAS = [ translation_key="expiry_date", device_class=SensorDeviceClass.TIMESTAMP, # raw value is epoch seconds - measurement_to_ha=datetime.fromtimestamp, + device_to_ha=datetime.fromtimestamp, ), entity_class=MatterSensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.ExpiryDate,), @@ -933,6 +1067,21 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="OperationalStateCountdownTime", + translation_key="estimated_end_time", + device_class=SensorDeviceClass.TIMESTAMP, + state_class=None, + # Add countdown to current datetime to get the estimated end time + device_to_ha=( + lambda x: dt_util.utcnow() + timedelta(seconds=x) if x > 0 else None + ), + ), + entity_class=MatterSensor, + required_attributes=(clusters.OperationalState.Attributes.CountdownTime,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterListSensorEntityDescription( @@ -984,7 +1133,7 @@ DISCOVERY_SCHEMAS = [ key="ThermostatLocalTemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - measurement_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / 100, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -999,6 +1148,8 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, translation_key="operational_state", state_list_attribute=clusters.RvcOperationalState.Attributes.OperationalStateList, + state_attribute=clusters.RvcOperationalState.Attributes.OperationalState, + state_map=RVC_OPERATIONAL_STATE_MAP, ), entity_class=MatterOperationalStateSensor, required_attributes=( @@ -1016,6 +1167,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, translation_key="operational_state", state_list_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + state_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalState, ), entity_class=MatterOperationalStateSensor, required_attributes=( @@ -1032,7 +1184,7 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="window_covering_target_position", - measurement_to_ha=lambda x: round((10000 - x) / 100), + device_to_ha=lambda x: round((10000 - x) / 100), native_unit_of_measurement=PERCENTAGE, ), entity_class=MatterSensor, @@ -1048,7 +1200,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=list(EVSE_FAULT_STATE_MAP.values()), - measurement_to_ha=EVSE_FAULT_STATE_MAP.get, + device_to_ha=EVSE_FAULT_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.EnergyEvse.Attributes.FaultState,), @@ -1113,6 +1265,18 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseStateOfCharge", + translation_key="evse_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.StateOfCharge,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -1161,7 +1325,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=list(ESA_STATE_MAP.values()), - measurement_to_ha=ESA_STATE_MAP.get, + device_to_ha=ESA_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.DeviceEnergyManagement.Attributes.ESAState,), @@ -1174,7 +1338,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=list(DEM_OPT_OUT_STATE_MAP.values()), - measurement_to_ha=DEM_OPT_OUT_STATE_MAP.get, + device_to_ha=DEM_OPT_OUT_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.DeviceEnergyManagement.Attributes.OptOutState,), @@ -1188,7 +1352,7 @@ DISCOVERY_SCHEMAS = [ options=[ mode for mode in PUMP_CONTROL_MODE_MAP.values() if mode is not None ], - measurement_to_ha=PUMP_CONTROL_MODE_MAP.get, + device_to_ha=PUMP_CONTROL_MODE_MAP.get, ), entity_class=MatterSensor, required_attributes=( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 35a9daa2370..f45baf8729d 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -83,8 +83,8 @@ "evse_plug": { "name": "Plug state" }, - "evse_supply_charging_state": { - "name": "Supply charging state" + "evse_supply_state": { + "name": "Charger supply state" }, "boost_state": { "name": "Boost state" @@ -111,6 +111,9 @@ }, "reset_filter_condition": { "name": "Reset filter condition" + }, + "self_test_request": { + "name": "Self-test" } }, "climate": { @@ -180,14 +183,29 @@ "altitude": { "name": "Altitude above sea level" }, + "cook_time": { + "name": "Cook time" + }, + "pump_setpoint": { + "name": "Setpoint" + }, "temperature_offset": { "name": "Temperature offset" }, + "temperature_setpoint": { + "name": "Temperature setpoint" + }, "pir_occupied_to_unoccupied_delay": { "name": "Occupied to unoccupied delay" }, "auto_relock_timer": { - "name": "Automatic relock timer" + "name": "Autorelock time" + }, + "led_indicator_intensity_off": { + "name": "LED off intensity" + }, + "led_indicator_intensity_on": { + "name": "LED on intensity" } }, "light": { @@ -210,6 +228,9 @@ "device_energy_management_mode": { "name": "Energy management mode" }, + "power_level": { + "name": "Power level (W)" + }, "sensitivity_level": { "name": "Sensitivity", "state": { @@ -298,7 +319,7 @@ "name": "Flow" }, "hepa_filter_condition": { - "name": "Hepa filter condition" + "name": "HEPA filter condition" }, "operational_state": { "name": "Operational state", @@ -312,6 +333,9 @@ "docked": "Docked" } }, + "estimated_end_time": { + "name": "Estimated end time" + }, "switch_current_position": { "name": "Current switch position" }, @@ -393,6 +417,9 @@ "other": "Other fault" } }, + "evse_soc": { + "name": "State of charge" + }, "pump_control_mode": { "name": "Control mode", "state": { @@ -407,6 +434,15 @@ "pump_speed": { "name": "Rotation speed" }, + "eve_weather_trend": { + "name": "Weather trend", + "state": { + "cloudy": "Cloudy", + "rainy": "Rainy", + "sunny": "Sunny", + "stormy": "Stormy" + } + }, "evse_circuit_capacity": { "name": "Circuit capacity" }, @@ -424,6 +460,24 @@ }, "window_covering_target_position": { "name": "Target opening position" + }, + "active_current": { + "name": "Active current" + }, + "apparent_current": { + "name": "Apparent current" + }, + "reactive_current": { + "name": "Reactive current" + }, + "rms_current": { + "name": "Effective current" + }, + "rms_voltage": { + "name": "Effective voltage" + }, + "voltage": { + "name": "Voltage" } }, "switch": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 870a9098492..df8581c5c4f 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -95,7 +95,7 @@ class MatterGenericCommandSwitch(MatterSwitch): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_is_on = value @@ -141,7 +141,7 @@ class MatterNumericSwitch(MatterSwitch): async def _async_set_native_value(self, value: bool) -> None: """Update the current value.""" - if value_convert := self.entity_description.ha_to_native_value: + if value_convert := self.entity_description.ha_to_device: send_value = value_convert(value) await self.write_attribute( value=send_value, @@ -159,7 +159,7 @@ class MatterNumericSwitch(MatterSwitch): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_is_on = value @@ -248,11 +248,11 @@ DISCOVERY_SCHEMAS = [ key="EveTrvChildLock", entity_category=EntityCategory.CONFIG, translation_key="child_lock", - measurement_to_ha={ + device_to_ha={ 0: False, 1: True, }.get, - ha_to_native_value={ + ha_to_device={ False: 0, True: 1, }.get, @@ -275,7 +275,7 @@ DISCOVERY_SCHEMAS = [ ), off_command=clusters.EnergyEvse.Commands.Disable, command_timeout=3000, - measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get, + device_to_ha=EVSE_SUPPLY_STATE_MAP.get, ), entity_class=MatterGenericCommandSwitch, required_attributes=( diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 5ea1716a37d..cf9f26adecb 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -17,6 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity @@ -30,10 +31,10 @@ class OperationalState(IntEnum): Combination of generic OperationalState and RvcOperationalState. """ - NO_ERROR = 0x00 - UNABLE_TO_START_OR_RESUME = 0x01 - UNABLE_TO_COMPLETE_OPERATION = 0x02 - COMMAND_INVALID_IN_STATE = 0x03 + STOPPED = 0x00 + RUNNING = 0x01 + PAUSED = 0x02 + ERROR = 0x03 SEEKING_CHARGER = 0x40 CHARGING = 0x41 DOCKED = 0x42 @@ -62,14 +63,36 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): _last_accepted_commands: list[int] | None = None _supported_run_modes: ( - dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None + dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None entity_description: StateVacuumEntityDescription _platform_translation_key = "vacuum" + def _get_run_mode_by_tag( + self, tag: ModeTag + ) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None: + """Get the run mode by tag.""" + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for t in mode.modeTags: + if t.value == tag.value: + return mode + return None + async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self.send_device_command(clusters.OperationalState.Commands.Stop()) + # We simply set the RvcRunMode to the first runmode + # that has the idle tag to stop the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + mode = self._get_run_mode_by_tag(ModeTag.IDLE) + if mode is None: + raise HomeAssistantError( + "No supported run mode found to stop the vacuum cleaner." + ) + + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" @@ -83,29 +106,40 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): """Start or resume the cleaning task.""" if TYPE_CHECKING: assert self._last_accepted_commands is not None + + accepted_operational_commands = self._last_accepted_commands if ( clusters.RvcOperationalState.Commands.Resume.command_id - in self._last_accepted_commands + in accepted_operational_commands + and self.state == VacuumActivity.PAUSED ): + # vacuum is paused and supports resume command await self.send_device_command( clusters.RvcOperationalState.Commands.Resume() ) - else: - await self.send_device_command(clusters.OperationalState.Commands.Start()) + return + + # We simply set the RvcRunMode to the first runmode + # that has the cleaning tag to start the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + 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." + ) + + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) async def async_pause(self) -> None: """Pause the cleaning task.""" - await self.send_device_command(clusters.OperationalState.Commands.Pause()) + await self.send_device_command(clusters.RvcOperationalState.Commands.Pause()) @callback def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() - # optional battery level - if VacuumEntityFeature.BATTERY & self._attr_supported_features: - self._attr_battery_level = self.get_matter_attribute_value( - clusters.PowerSource.Attributes.BatPercentRemaining - ) # derive state from the run mode + operational state run_mode_raw: int = self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.CurrentMode @@ -120,17 +154,18 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): state = VacuumActivity.DOCKED elif operational_state == OperationalState.SEEKING_CHARGER: state = VacuumActivity.RETURNING - elif operational_state in ( - OperationalState.UNABLE_TO_COMPLETE_OPERATION, - OperationalState.UNABLE_TO_START_OR_RESUME, - ): + elif operational_state == OperationalState.ERROR: state = VacuumActivity.ERROR + elif operational_state == OperationalState.PAUSED: + state = VacuumActivity.PAUSED elif (run_mode := self._supported_run_modes.get(run_mode_raw)) is not None: tags = {x.value for x in run_mode.modeTags} if ModeTag.CLEANING in tags: state = VacuumActivity.CLEANING elif ModeTag.IDLE in tags: state = VacuumActivity.IDLE + elif ModeTag.MAPPING in tags: + state = VacuumActivity.CLEANING self._attr_activity = state @callback @@ -144,17 +179,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): return self._last_accepted_commands = accepted_operational_commands supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + supported_features |= VacuumEntityFeature.START supported_features |= VacuumEntityFeature.STATE - # optional battery attribute = battery feature - if self.get_matter_attribute_value( - clusters.PowerSource.Attributes.BatPercentRemaining - ): - supported_features |= VacuumEntityFeature.BATTERY + supported_features |= VacuumEntityFeature.STOP + # optional identify cluster = locate feature (value must be not None or 0) if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType): supported_features |= VacuumEntityFeature.LOCATE # create a map of supported run modes - run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = ( + run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = ( self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.SupportedModes ) @@ -166,22 +199,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): in accepted_operational_commands ): supported_features |= VacuumEntityFeature.PAUSE - if ( - clusters.OperationalState.Commands.Stop.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.STOP - if ( - clusters.OperationalState.Commands.Start.command_id - in accepted_operational_commands - ): - # note that start has been replaced by resume in rev2 of the spec - supported_features |= VacuumEntityFeature.START - if ( - clusters.RvcOperationalState.Commands.Resume.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.START if ( clusters.RvcOperationalState.Commands.GoHome.command_id in accepted_operational_commands @@ -201,11 +218,7 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterVacuum, required_attributes=( clusters.RvcRunMode.Attributes.CurrentMode, - clusters.RvcOperationalState.Attributes.CurrentPhase, - ), - optional_attributes=( - clusters.RvcCleanMode.Attributes.CurrentMode, - clusters.PowerSource.Attributes.BatPercentRemaining, + clusters.RvcOperationalState.Attributes.OperationalState, ), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index e8df68de5e2..e218691975a 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -32,11 +32,18 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + errors: dict[str, str] = {} llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} if user_input is not None: - return self.async_create_entry( - title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input - ) + if not user_input[CONF_LLM_HASS_API]: + errors[CONF_LLM_HASS_API] = "llm_api_required" + else: + return self.async_create_entry( + title=", ".join( + llm_apis[api_id] for api_id in user_input[CONF_LLM_HASS_API] + ), + data=user_input, + ) return self.async_show_form( step_id="user", @@ -44,7 +51,7 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Optional( CONF_LLM_HASS_API, - default=llm.LLM_API_ASSIST, + default=[llm.LLM_API_ASSIST], ): SelectSelector( SelectSelectorConfig( options=[ @@ -53,10 +60,12 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): value=llm_api_id, ) for llm_api_id, name in llm_apis.items() - ] + ], + multiple=True, ) ), } ), description_placeholders={"more_info_url": MORE_INFO_URL}, + errors=errors, ) diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index affa4faecd6..953fc1314da 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -42,7 +42,7 @@ def _format_tool( async def create_server( - hass: HomeAssistant, llm_api_id: str, llm_context: llm.LLMContext + hass: HomeAssistant, llm_api_id: str | list[str], llm_context: llm.LLMContext ) -> Server: """Create a new Model Context Protocol Server. diff --git a/homeassistant/components/mcp_server/strings.json b/homeassistant/components/mcp_server/strings.json index 57f1baf183c..602030475ea 100644 --- a/homeassistant/components/mcp_server/strings.json +++ b/homeassistant/components/mcp_server/strings.json @@ -11,6 +11,9 @@ } } }, + "error": { + "llm_api_required": "At least one LLM API must be configured." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index c040d665794..e729265bcbc 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -8,7 +8,6 @@ DOMAIN = "mealie" LOGGER = logging.getLogger(__package__) -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" ATTR_RECIPE_ID = "recipe_id" @@ -17,5 +16,7 @@ ATTR_INCLUDE_TAGS = "include_tags" ATTR_ENTRY_TYPE = "entry_type" ATTR_NOTE_TITLE = "note_title" ATTR_NOTE_TEXT = "note_text" +ATTR_SEARCH_TERMS = "search_terms" +ATTR_RESULT_LIMIT = "result_limit" MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v1.0.0") diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index d7e29cc8bbe..773d70afa5f 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -30,6 +30,9 @@ "get_recipe": { "service": "mdi:map" }, + "get_recipes": { + "service": "mdi:book-open-page-variant" + }, "import_recipe": { "service": "mdi:map-search" }, diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index d90e979582e..a744b9e6ced 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["aiomealie==0.9.6"] + "quality_scale": "silver", + "requirements": ["aiomealie==0.10.1"] } diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 0d9a29392a4..37b485e18f2 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -13,7 +13,7 @@ from aiomealie import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -25,13 +25,14 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, ATTR_NOTE_TEXT, ATTR_NOTE_TITLE, ATTR_RECIPE_ID, + ATTR_RESULT_LIMIT, + ATTR_SEARCH_TERMS, ATTR_START_DATE, ATTR_URL, DOMAIN, @@ -55,6 +56,15 @@ SERVICE_GET_RECIPE_SCHEMA = vol.Schema( } ) +SERVICE_GET_RECIPES = "get_recipes" +SERVICE_GET_RECIPES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Optional(ATTR_SEARCH_TERMS): str, + vol.Optional(ATTR_RESULT_LIMIT): int, + } +) + SERVICE_IMPORT_RECIPE = "import_recipe" SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema( { @@ -159,6 +169,27 @@ async def _async_get_recipe(call: ServiceCall) -> ServiceResponse: return {"recipe": asdict(recipe)} +async def _async_get_recipes(call: ServiceCall) -> ServiceResponse: + """Get recipes.""" + entry = _async_get_entry(call) + search_terms = call.data.get(ATTR_SEARCH_TERMS) + result_limit = call.data.get(ATTR_RESULT_LIMIT, 10) + client = entry.runtime_data.client + try: + recipes = await client.get_recipes(search=search_terms, per_page=result_limit) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + except MealieNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_recipes_found", + ) from err + return {"recipes": asdict(recipes)} + + async def _async_import_recipe(call: ServiceCall) -> ServiceResponse: """Import a recipe.""" entry = _async_get_entry(call) @@ -242,6 +273,13 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_RECIPE_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_RECIPES, + _async_get_recipes, + schema=SERVICE_GET_RECIPES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_IMPORT_RECIPE, diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 47a79ba5756..6a78564a578 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -24,6 +24,27 @@ get_recipe: selector: text: +get_recipes: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mealie + search_terms: + required: false + selector: + text: + result_limit: + required: false + default: 10 + selector: + number: + min: 1 + max: 100 + mode: box + unit_of_measurement: recipes + import_recipe: fields: config_entry_id: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 186fc4c4ac0..5533631f755 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -109,6 +109,9 @@ "recipe_not_found": { "message": "Recipe with ID or slug `{recipe_id}` not found." }, + "no_recipes_found": { + "message": "No recipes found matching your search." + }, "could_not_import_recipe": { "message": "Mealie could not import the recipe from the URL." }, @@ -176,6 +179,24 @@ } } }, + "get_recipes": { + "name": "Get recipes", + "description": "Searches for recipes with any matching properties in Mealie", + "fields": { + "config_entry_id": { + "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", + "description": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::description%]" + }, + "search_terms": { + "name": "Search terms", + "description": "Terms to search for in recipe properties." + }, + "result_limit": { + "name": "Result limit", + "description": "Maximum number of recipes to return (default: 10)." + } + } + }, "import_recipe": { "name": "Import recipe", "description": "Imports a recipe from an URL", diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index d42c9033922..c701af2865c 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -130,6 +130,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): list_id=self._shopping_list_id, note=item.summary.strip() if item.summary else item.summary, position=position, + quantity=0.0, ) try: await self.coordinator.client.add_shopping_item(new_shopping_item) @@ -174,7 +175,8 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): if list_item.display.strip() != stripped_item_summary: update_shopping_item.note = stripped_item_summary update_shopping_item.position = position - update_shopping_item.is_food = False + if update_shopping_item.is_food is not None: + update_shopping_item.is_food = False update_shopping_item.food_id = None update_shopping_item.quantity = 0.0 update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED @@ -249,7 +251,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): mutate_shopping_item.note = item.note mutate_shopping_item.checked = item.checked - if item.is_food: + if item.is_food or item.food_id: mutate_shopping_item.food_id = item.food_id mutate_shopping_item.unit_id = item.unit_id diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 0a9fa77f902..9f35d941b65 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import MEATER_DATA from .coordinator import MeaterConfigEntry, MeaterCoordinator PLATFORMS = [Platform.SENSOR] @@ -15,7 +15,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo coordinator = MeaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}).setdefault("known_probes", set()) + hass.data.setdefault(MEATER_DATA, set()) entry.runtime_data = coordinator @@ -25,4 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[MEATER_DATA] = ( + hass.data[MEATER_DATA] - entry.runtime_data.found_probes + ) + return unload_ok diff --git a/homeassistant/components/meater/const.py b/homeassistant/components/meater/const.py index 6b40aa18d59..ac3a238856b 100644 --- a/homeassistant/components/meater/const.py +++ b/homeassistant/components/meater/const.py @@ -1,3 +1,7 @@ """Constants for the Meater Temperature Probe integration.""" +from homeassistant.util.hass_dict import HassKey + DOMAIN = "meater" + +MEATER_DATA: HassKey[set[str]] = HassKey(DOMAIN) diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py index 042a3c87b0c..9a9910f6e1a 100644 --- a/homeassistant/components/meater/coordinator.py +++ b/homeassistant/components/meater/coordinator.py @@ -44,6 +44,7 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): ) session = async_get_clientsession(hass) self.client = MeaterApi(session) + self.found_probes: set[str] = set() async def _async_setup(self) -> None: """Set up the Meater Coordinator.""" @@ -73,5 +74,6 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): raise UpdateFailed( "Too many requests have been made to the API, rate limiting is in place" ) from err - - return {device.id: device for device in devices} + res = {device.id: device for device in devices} + self.found_probes.update(set(res.keys())) + return res diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 61833babd47..6f180386520 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from . import MeaterCoordinator -from .const import DOMAIN +from .const import DOMAIN, MEATER_DATA from .coordinator import MeaterConfigEntry COOK_STATES = { @@ -42,8 +42,8 @@ COOK_STATES = { class MeaterSensorEntityDescription(SensorEntityDescription): """Describes meater sensor entity.""" - available: Callable[[MeaterProbe | None], bool] value: Callable[[MeaterProbe], datetime | float | str | None] + unavailable_when_not_cooking: bool = False def _elapsed_time_to_timestamp(probe: MeaterProbe) -> datetime | None: @@ -72,7 +72,6 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None, value=lambda probe: probe.ambient_temperature, ), # Internal temperature (probe tip) @@ -82,20 +81,19 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None, value=lambda probe: probe.internal_temperature, ), # Name of selected meat in user language or user given custom name MeaterSensorEntityDescription( key="cook_name", translation_key="cook_name", - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, value=lambda probe: probe.cook.name if probe.cook else None, ), MeaterSensorEntityDescription( key="cook_state", translation_key="cook_state", - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, device_class=SensorDeviceClass.ENUM, options=list(COOK_STATES.values()), value=lambda probe: COOK_STATES.get(probe.cook.state) if probe.cook else None, @@ -107,10 +105,12 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None and probe.cook is not None, - value=lambda probe: probe.cook.target_temperature - if probe.cook and hasattr(probe.cook, "target_temperature") - else None, + unavailable_when_not_cooking=True, + value=( + lambda probe: probe.cook.target_temperature + if probe.cook and hasattr(probe.cook, "target_temperature") + else None + ), ), # Peak temperature MeaterSensorEntityDescription( @@ -119,10 +119,12 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None and probe.cook is not None, - value=lambda probe: probe.cook.peak_temperature - if probe.cook and hasattr(probe.cook, "peak_temperature") - else None, + unavailable_when_not_cooking=True, + value=( + lambda probe: probe.cook.peak_temperature + if probe.cook and hasattr(probe.cook, "peak_temperature") + else None + ), ), # Remaining time in seconds. When unknown/calculating default is used. Default: -1 # Exposed as a TIMESTAMP sensor where the timestamp is current time + remaining time. @@ -130,7 +132,7 @@ SENSOR_TYPES = ( key="cook_time_remaining", translation_key="cook_time_remaining", device_class=SensorDeviceClass.TIMESTAMP, - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, value=_remaining_time_to_timestamp, ), # Time since the start of cook in seconds. Default: 0. Exposed as a TIMESTAMP sensor @@ -139,7 +141,7 @@ SENSOR_TYPES = ( key="cook_time_elapsed", translation_key="cook_time_elapsed", device_class=SensorDeviceClass.TIMESTAMP, - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, value=_elapsed_time_to_timestamp, ), ) @@ -161,7 +163,7 @@ async def async_setup_entry( devices = coordinator.data entities = [] - known_probes: set = hass.data[DOMAIN]["known_probes"] + known_probes = hass.data[MEATER_DATA] # Add entities for temperature probes which we've not yet seen for device_id in devices: @@ -188,10 +190,14 @@ async def async_setup_entry( class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]): """Meater Temperature Sensor Entity.""" + _attr_has_entity_name = True entity_description: MeaterSensorEntityDescription def __init__( - self, coordinator, device_id, description: MeaterSensorEntityDescription + self, + coordinator: MeaterCoordinator, + device_id: str, + description: MeaterSensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(coordinator) @@ -202,7 +208,7 @@ class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]) }, manufacturer="Apption Labs", model="Meater Probe", - name=f"Meater Probe {device_id}", + name=f"Meater Probe {device_id[:8]}", ) self._attr_unique_id = f"{device_id}-{description.key}" @@ -210,20 +216,24 @@ class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]) self.entity_description = description @property - def native_value(self): - """Return the temperature of the probe.""" - if not (device := self.coordinator.data.get(self.device_id)): - return None + def probe(self) -> MeaterProbe: + """Return the probe.""" + return self.coordinator.data[self.device_id] - return self.entity_description.value(device) + @property + def native_value(self) -> datetime | float | str | None: + """Return the temperature of the probe.""" + return self.entity_description.value(self.probe) @property def available(self) -> bool: """Return if entity is available.""" # See if the device was returned from the API. If not, it's offline return ( - self.coordinator.last_update_success - and self.entity_description.available( - self.coordinator.data.get(self.device_id) + super().available + and self.device_id in self.coordinator.data + and ( + not self.entity_description.unavailable_when_not_cooking + or self.probe.cook is not None ) ) diff --git a/homeassistant/components/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py index 8603e1b9ce5..5c508688b54 100644 --- a/homeassistant/components/medcom_ble/__init__.py +++ b/homeassistant/components/medcom_ble/__init__.py @@ -2,34 +2,23 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from bleak import BleakError -from medcom_ble import MedcomBleDeviceData - 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN +from .coordinator import MedcomBleUpdateCoordinator # Supported platforms PLATFORMS: list[Platform] = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Medcom BLE radiation monitor from a config entry.""" address = entry.unique_id - elevation = hass.config.elevation - is_metric = hass.config.units is METRIC_SYSTEM assert address is not None ble_device = bluetooth.async_ble_device_from_address(hass, address) @@ -38,26 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Medcom BLE device with address {address}" ) - async def _async_update_method(): - """Get data from Medcom BLE radiation monitor.""" - ble_device = bluetooth.async_ble_device_from_address(hass, address) - inspector = MedcomBleDeviceData(_LOGGER, elevation, is_metric) - - try: - data = await inspector.update_device(ble_device) - except BleakError as err: - raise UpdateFailed(f"Unable to fetch data: {err}") from err - - return data - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=_async_update_method, - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) + coordinator = MedcomBleUpdateCoordinator(hass, entry, address) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/medcom_ble/coordinator.py b/homeassistant/components/medcom_ble/coordinator.py new file mode 100644 index 00000000000..2b326c4196d --- /dev/null +++ b/homeassistant/components/medcom_ble/coordinator.py @@ -0,0 +1,50 @@ +"""The Medcom BLE integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from bleak import BleakError +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MedcomBleUpdateCoordinator(DataUpdateCoordinator[MedcomBleDevice]): + """Coordinator for Medcom BLE radiation monitor data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, address: str) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._address = address + self._elevation = hass.config.elevation + self._is_metric = hass.config.units is METRIC_SYSTEM + + async def _async_update_data(self) -> MedcomBleDevice: + """Get data from Medcom BLE radiation monitor.""" + ble_device = bluetooth.async_ble_device_from_address(self.hass, self._address) + inspector = MedcomBleDeviceData(_LOGGER, self._elevation, self._is_metric) + + try: + data = await inspector.update_device(ble_device) + except BleakError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + return data diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py index f837620c829..cf78b5dc41a 100644 --- a/homeassistant/components/medcom_ble/sensor.py +++ b/homeassistant/components/medcom_ble/sensor.py @@ -4,8 +4,6 @@ from __future__ import annotations import logging -from medcom_ble import MedcomBleDevice - from homeassistant import config_entries from homeassistant.components.sensor import ( SensorEntity, @@ -15,12 +13,10 @@ from homeassistant.components.sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, 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, UNIT_CPM +from .coordinator import MedcomBleUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -41,9 +37,7 @@ async def async_setup_entry( ) -> None: """Set up Medcom BLE radiation monitor sensors.""" - coordinator: DataUpdateCoordinator[MedcomBleDevice] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: MedcomBleUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) @@ -62,16 +56,14 @@ async def async_setup_entry( async_add_entities(entities) -class MedcomSensor( - CoordinatorEntity[DataUpdateCoordinator[MedcomBleDevice]], SensorEntity -): +class MedcomSensor(CoordinatorEntity[MedcomBleUpdateCoordinator], SensorEntity): """Medcom BLE radiation monitor sensors for the device.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[MedcomBleDevice], + coordinator: MedcomBleUpdateCoordinator, entity_description: SensorEntityDescription, ) -> None: """Populate the medcom entity with relevant data.""" diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 20068efccef..477e77022de 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]==2025.06.09"], + "requirements": ["yt-dlp[default]==2025.08.11"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index d0c6bcabfcf..b2cb7d76e8f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1041,7 +1041,8 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self.state in { MediaPlayerState.OFF, - MediaPlayerState.STANDBY, + # Not comparing to MediaPlayerState.STANDBY to avoid deprecation warning + "standby", }: await self.async_turn_on() else: diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 8d85d7cd106..f842ccccb65 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -5,6 +5,7 @@ from functools import partial from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -50,7 +51,13 @@ ATTR_SOUND_MODE_LIST = "sound_mode_list" DOMAIN = "media_player" -class MediaPlayerState(StrEnum): +class MediaPlayerState( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "STANDBY": ("MediaPlayerState.OFF or MediaPlayerState.IDLE", "2026.8.0"), + }, +): """State of media player entities.""" OFF = "off" diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index be365694579..9b714fdf52d 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -1,5 +1,6 @@ """Intents for the media_player integration.""" +import asyncio from collections.abc import Iterable from dataclasses import dataclass, field import logging @@ -14,21 +15,21 @@ from homeassistant.const import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, + STATE_PLAYING, ) from homeassistant.core import Context, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers.entity_component import EntityComponent -from . import ( +from . import MediaPlayerDeviceClass, MediaPlayerEntity +from .browse_media import SearchMedia +from .const import ( + ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, - MediaPlayerDeviceClass, - SearchMedia, -) -from .const import ( - ATTR_MEDIA_FILTER_CLASSES, MediaClass, MediaPlayerEntityFeature, MediaPlayerState, @@ -39,6 +40,7 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative" INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" _LOGGER = logging.getLogger(__name__) @@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: device_classes={MediaPlayerDeviceClass}, ), ) + intent.async_register(hass, MediaSetVolumeRelativeHandler()) intent.async_register(hass, MediaSearchAndPlayHandler()) @@ -354,3 +357,120 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): response.async_set_speech_slots({"media": first_result.as_dict()}) response.response_type = intent.IntentResponseType.ACTION_DONE return response + + +class MediaSetVolumeRelativeHandler(intent.IntentHandler): + """Handler for setting relative volume.""" + + description = "Increases or decreases the volume of a media player" + + intent_type = INTENT_SET_VOLUME_RELATIVE + slot_schema = { + vol.Required("volume_step"): vol.Any( + "up", + "down", + vol.All( + vol.Coerce(int), + vol.Range(min=-100, max=100), + lambda val: val / 100, + ), + ), + # Optional name/area/floor slots handled by intent matcher + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + slots = self.async_validate_slots(intent_obj.slots) + volume_step = slots["volume_step"]["value"] + + # Entity name to match + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + # Get area/floor info + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + + # Find matching entities + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains={DOMAIN}, + assistant=intent_obj.assistant, + features=MediaPlayerEntityFeature.VOLUME_SET, + ) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + + if not match_result.is_match: + # No targets + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + if ( + match_result.is_match + and (len(match_result.states) > 1) + and ("name" not in intent_obj.slots) + ): + # Multiple targets not by name, so we need to check state + match_result.states = [ + s for s in match_result.states if s.state == STATE_PLAYING + ] + if not match_result.states: + # No media players are playing + raise intent.MatchFailedError( + result=intent.MatchTargetsResult( + is_match=False, no_match_reason=intent.MatchFailedReason.STATE + ), + constraints=match_constraints, + preferences=match_preferences, + ) + + target_entity_ids = {s.entity_id for s in match_result.states} + target_entities = [ + e for e in component.entities if e.entity_id in target_entity_ids + ] + + if volume_step == "up": + coros = [e.async_volume_up() for e in target_entities] + elif volume_step == "down": + coros = [e.async_volume_down() for e in target_entities] + else: + coros = [ + e.async_set_volume_level( + max(0.0, min(1.0, e.volume_level + volume_step)) + ) + for e in target_entities + ] + + try: + await asyncio.gather(*coros) + except HomeAssistantError as err: + _LOGGER.error("Error setting relative volume: %s", err) + raise intent.IntentHandleError( + f"Error setting relative volume: {err}" + ) from err + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_states(match_result.states) + return response diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index e1e9a4feb4b..efd7c6670d2 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -30,6 +30,7 @@ from .const import ( DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, + MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX, ) @@ -78,7 +79,7 @@ def generate_media_source_id(domain: str, identifier: str) -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media_source component.""" - hass.data[DOMAIN] = {} + hass.data[MEDIA_SOURCE_DATA] = {} websocket_api.async_register_command(hass, websocket_browse_media) websocket_api.async_register_command(hass, websocket_resolve_media) frontend.async_register_built_in_panel( @@ -97,7 +98,7 @@ async def _process_media_source_platform( platform: MediaSourceProtocol, ) -> None: """Process a media source platform.""" - hass.data[DOMAIN][domain] = await platform.async_get_media_source(hass) + hass.data[MEDIA_SOURCE_DATA][domain] = await platform.async_get_media_source(hass) @callback @@ -109,10 +110,10 @@ def _get_media_item( item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player) else: # We default to our own domain if its only one registered - domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN + domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN return MediaSourceItem(hass, domain, "", target_media_player) - if item.domain is not None and item.domain not in hass.data[DOMAIN]: + if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]: raise UnknownMediaSource( translation_domain=DOMAIN, translation_key="unknown_media_source", diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index 809e0d8a1fd..38c75f19b22 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -1,10 +1,18 @@ """Constants for the media_source integration.""" +from __future__ import annotations + import re +from typing import TYPE_CHECKING from homeassistant.components.media_player import MediaClass +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .models import MediaSource DOMAIN = "media_source" +MEDIA_SOURCE_DATA: HassKey[dict[str, MediaSource]] = HassKey(DOMAIN) MEDIA_MIME_TYPES = ("audio", "video", "image") MEDIA_CLASS_MAP = { "audio": MediaClass.MUSIC, diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 7916f72c6b9..fa30dc9baf3 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -6,7 +6,7 @@ import logging import mimetypes from pathlib import Path import shutil -from typing import Any +from typing import Any, cast from aiohttp import web from aiohttp.web_request import FileField @@ -18,7 +18,7 @@ from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.core import HomeAssistant, callback from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path -from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES +from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia @@ -30,7 +30,7 @@ LOGGER = logging.getLogger(__name__) def async_setup(hass: HomeAssistant) -> None: """Set up local media source.""" source = LocalSource(hass) - hass.data[DOMAIN][DOMAIN] = source + hass.data[MEDIA_SOURCE_DATA][DOMAIN] = source hass.http.register_view(LocalMediaView(hass, source)) hass.http.register_view(UploadMediaView(hass, source)) websocket_api.async_register_command(hass, websocket_remove_media) @@ -80,7 +80,7 @@ class LocalSource(MediaSource): path = self.async_full_path(source_dir_id, location) mime_type, _ = mimetypes.guess_type(str(path)) assert isinstance(mime_type, str) - return PlayMedia(f"/media/{item.identifier}", mime_type) + return PlayMedia(f"/media/{item.identifier}", mime_type, path=path) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Return media.""" @@ -210,10 +210,8 @@ class LocalMediaView(http.HomeAssistantView): self.hass = hass self.source = source - async def get( - self, request: web.Request, source_dir_id: str, location: str - ) -> web.FileResponse: - """Start a GET request.""" + async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: + """Validate media path and return it if valid.""" try: raise_if_invalid_path(location) except ValueError as err: @@ -233,6 +231,25 @@ class LocalMediaView(http.HomeAssistantView): if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: raise web.HTTPNotFound + return media_path + + async def head( + self, request: web.Request, source_dir_id: str, location: str + ) -> None: + """Handle a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the location exists or not. + """ + await self._validate_media_path(source_dir_id, location) + + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.FileResponse: + """Handle a GET request.""" + media_path = await self._validate_media_path(source_dir_id, location) return web.FileResponse(media_path) @@ -335,7 +352,7 @@ async def websocket_remove_media( connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return - source: LocalSource = hass.data[DOMAIN][DOMAIN] + source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][DOMAIN]) try: source_dir_id, location = source.async_parse_identifier(item) diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 53bd8213262..2cf5d231741 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -2,13 +2,16 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, cast +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, URI_SCHEME, URI_SCHEME_REGEX +from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX + +if TYPE_CHECKING: + from pathlib import Path @dataclass(slots=True) @@ -17,6 +20,7 @@ class PlayMedia: url: str mime_type: str + path: Path | None = field(kw_only=True, default=None) class BrowseMediaSource(BrowseMedia): @@ -45,6 +49,16 @@ class MediaSourceItem: identifier: str target_media_player: str | None + @property + def media_source_id(self) -> str: + """Return the media source ID.""" + uri = URI_SCHEME + if self.domain: + uri += self.domain + if self.identifier: + uri += f"/{self.identifier}" + return uri + async def async_browse(self) -> BrowseMediaSource: """Browse this item.""" if self.domain is None: @@ -70,7 +84,7 @@ class MediaSourceItem: can_play=False, can_expand=True, ) - for source in self.hass.data[DOMAIN].values() + for source in self.hass.data[MEDIA_SOURCE_DATA].values() ), key=lambda item: item.title, ) @@ -85,7 +99,9 @@ class MediaSourceItem: @callback def async_media_source(self) -> MediaSource: """Return media source that owns this item.""" - return cast(MediaSource, self.hass.data[DOMAIN][self.domain]) + if TYPE_CHECKING: + assert self.domain is not None + return self.hass.data[MEDIA_SOURCE_DATA][self.domain] @classmethod def from_uri( diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index bccbe9f66ac..c7f7ee12ae8 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -134,7 +134,7 @@ class MediaroomDevice(MediaPlayerEntity): state_map = { State.OFF: MediaPlayerState.OFF, - State.STANDBY: MediaPlayerState.STANDBY, + State.STANDBY: MediaPlayerState.IDLE, State.PLAYING_LIVE_TV: MediaPlayerState.PLAYING, State.PLAYING_RECORDED_TV: MediaPlayerState.PLAYING, State.PLAYING_TIMESHIFT_TV: MediaPlayerState.PLAYING, @@ -155,7 +155,7 @@ class MediaroomDevice(MediaPlayerEntity): self._channel = None self._optimistic = optimistic self._attr_state = ( - MediaPlayerState.PLAYING if optimistic else MediaPlayerState.STANDBY + MediaPlayerState.PLAYING if optimistic else MediaPlayerState.IDLE ) self._name = f"Mediaroom {device_id if device_id else host}" self._available = True @@ -254,7 +254,7 @@ class MediaroomDevice(MediaPlayerEntity): try: self.set_state(await self.stb.turn_off()) if self._optimistic: - self._attr_state = MediaPlayerState.STANDBY + self._attr_state = MediaPlayerState.IDLE self._available = True except PyMediaroomError: self._available = False diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 30645661ff1..d78807106c1 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -27,9 +27,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] +type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDevice]]] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Establish connection with MELClooud.""" + +async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool: + """Establish connection with MELCloud.""" conf = entry.data try: mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) @@ -40,20 +42,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (TimeoutError, ClientConnectionError) as ex: raise ConfigEntryNotReady from ex - hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) + entry.runtime_data = mel_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> 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) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) class MelCloudDevice: diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 19c333e5825..b5fd57c716d 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -24,13 +24,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MelCloudDevice +from . import MelCloudConfigEntry, MelCloudDevice from .const import ( ATTR_STATUS, ATTR_VANE_HORIZONTAL, @@ -38,7 +37,6 @@ from .const import ( ATTR_VANE_VERTICAL, ATTR_VANE_VERTICAL_POSITIONS, CONF_POSITION, - DOMAIN, SERVICE_SET_VANE_HORIZONTAL, SERVICE_SET_VANE_VERTICAL, ) @@ -77,11 +75,11 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" - mel_devices = hass.data[DOMAIN][entry.entry_id] + mel_devices = entry.runtime_data entities: list[AtaDeviceClimate | AtwDeviceZoneClimate] = [ AtaDeviceClimate(mel_device, mel_device.device) for mel_device in mel_devices[DEVICE_TYPE_ATA] diff --git a/homeassistant/components/melcloud/diagnostics.py b/homeassistant/components/melcloud/diagnostics.py index 31e52bf2bde..4606b7c25e5 100644 --- a/homeassistant/components/melcloud/diagnostics.py +++ b/homeassistant/components/melcloud/diagnostics.py @@ -5,11 +5,12 @@ 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, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import MelCloudConfigEntry + TO_REDACT = { CONF_USERNAME, CONF_TOKEN, @@ -17,7 +18,7 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: MelCloudConfigEntry ) -> dict[str, Any]: """Return diagnostics for the config entry.""" ent_reg = er.async_get(hass) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 51a026e717a..36800b2645d 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -15,13 +15,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MelCloudDevice -from .const import DOMAIN +from . import MelCloudConfigEntry, MelCloudDevice @dataclasses.dataclass(frozen=True, kw_only=True) @@ -105,11 +103,11 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud device sensors based on config_entry.""" - mel_devices = hass.data[DOMAIN].get(entry.entry_id) + mel_devices = entry.runtime_data entities: list[MelDeviceSensor] = [ MelDeviceSensor(mel_device, description) diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index 76fbad41575..f006df2478e 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -17,22 +17,21 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, MelCloudDevice +from . import MelCloudConfigEntry, MelCloudDevice from .const import ATTR_STATUS async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" - mel_devices = hass.data[DOMAIN][entry.entry_id] + mel_devices = entry.runtime_data async_add_entities( [ AtwWaterHeater(mel_device, mel_device.device) diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 6ab725d747c..2d9faf91bd2 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -6,13 +6,11 @@ from melnor_bluetooth.device import Device from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.NUMBER, @@ -22,11 +20,8 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bool: """Set up melnor from a config entry.""" - - hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) - ble_device = bluetooth.async_ble_device_from_address(hass, entry.data[CONF_ADDRESS]) if not ble_device: @@ -60,20 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = MelnorDataUpdateCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.data.disconnect() - device: Device = hass.data[DOMAIN][entry.entry_id].data - - await device.disconnect() - - 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/melnor/coordinator.py b/homeassistant/components/melnor/coordinator.py index 52662fd0c4c..a57a1816e37 100644 --- a/homeassistant/components/melnor/coordinator.py +++ b/homeassistant/components/melnor/coordinator.py @@ -11,15 +11,17 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type MelnorConfigEntry = ConfigEntry[MelnorDataUpdateCoordinator] + class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Melnor data update coordinator.""" - config_entry: ConfigEntry + config_entry: MelnorConfigEntry _device: Device def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device + self, hass: HomeAssistant, config_entry: MelnorConfigEntry, device: Device ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index 42c22ae5a43..863faf080bd 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -13,13 +13,11 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorZoneEntity, get_entities_for_valves @@ -67,12 +65,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( get_entities_for_valves( diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 525a29dc6cf..e645019f1e8 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -26,8 +25,7 @@ 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 MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorBluetoothEntity, MelnorZoneEntity, get_entities_for_valves @@ -104,12 +102,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Device-level sensors async_add_entities( diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index cc5abe8f6f3..d0240a471b6 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -13,12 +13,10 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorZoneEntity, get_entities_for_valves @@ -51,12 +49,12 @@ ZONE_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( get_entities_for_valves( diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 277eb6e36eb..978801dd64c 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -10,13 +10,11 @@ from typing import Any from melnor_bluetooth.device import Valve from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorZoneEntity, get_entities_for_valves @@ -41,12 +39,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( get_entities_for_valves( diff --git a/homeassistant/components/mercury_nz/__init__.py b/homeassistant/components/mercury_nz/__init__.py deleted file mode 100644 index ff22fc5ce4a..00000000000 --- a/homeassistant/components/mercury_nz/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: Mercury NZ Limited.""" diff --git a/homeassistant/components/mercury_nz/manifest.json b/homeassistant/components/mercury_nz/manifest.json deleted file mode 100644 index d9d30787067..00000000000 --- a/homeassistant/components/mercury_nz/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "mercury_nz", - "name": "Mercury NZ Limited", - "integration_type": "virtual", - "supported_by": "opower" -} diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 17fc411bf20..d5f80d442a4 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry( config_entry.runtime_data = coordinator - config_entry.async_on_unload(config_entry.add_update_listener(async_update_entry)) config_entry.async_on_unload(coordinator.untrack_home) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -64,11 +63,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_update_entry(hass: HomeAssistant, config_entry: MetWeatherConfigEntry): - """Reload Met component when options changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def cleanup_old_device(hass: HomeAssistant) -> None: """Cleanup device without proper device identifier.""" device_reg = dr.async_get(hass) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index e5db80b2997..54d528a7406 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ELEVATION, @@ -147,7 +147,7 @@ class MetConfigFlowHandler(ConfigFlow, domain=DOMAIN): return MetOptionsFlowHandler() -class MetOptionsFlowHandler(OptionsFlow): +class MetOptionsFlowHandler(OptionsFlowWithReload): """Options flow for Met component.""" async def async_step_init( diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 62d7d21134c..05be5134283 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,59 +1,21 @@ """The met_eireann component.""" -from collections.abc import Mapping -from datetime import timedelta -import logging -from typing import Any, Self - -import meteireann - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, Platform +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 homeassistant.util import dt as dt_util from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -UPDATE_INTERVAL = timedelta(minutes=60) +from .coordinator import MetEireannUpdateCoordinator PLATFORMS = [Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Met Éireann as config entry.""" - hass.data.setdefault(DOMAIN, {}) - - raw_weather_data = meteireann.WeatherData( - async_get_clientsession(hass), - latitude=config_entry.data[CONF_LATITUDE], - longitude=config_entry.data[CONF_LONGITUDE], - altitude=config_entry.data[CONF_ELEVATION], - ) - - weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data) - - async def _async_update_data() -> MetEireannWeatherData: - """Fetch data from Met Éireann.""" - try: - return await weather_data.fetch_data() - except Exception as err: - raise UpdateFailed(f"Update failed: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=config_entry, - name=DOMAIN, - update_method=_async_update_data, - update_interval=UPDATE_INTERVAL, - ) + coordinator = MetEireannUpdateCoordinator(hass, config_entry=config_entry) await coordinator.async_refresh() - hass.data[DOMAIN][config_entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -68,26 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok - - -class MetEireannWeatherData: - """Keep data for Met Éireann weather entities.""" - - def __init__( - self, config: Mapping[str, Any], weather_data: meteireann.WeatherData - ) -> None: - """Initialise the weather entity data.""" - self._config = config - self._weather_data = weather_data - self.current_weather_data: dict[str, Any] = {} - self.daily_forecast: list[dict[str, Any]] = [] - self.hourly_forecast: list[dict[str, Any]] = [] - - async def fetch_data(self) -> Self: - """Fetch data from API - (current weather and forecast).""" - await self._weather_data.fetching_data() - self.current_weather_data = self._weather_data.get_current_weather() - time_zone = dt_util.get_default_time_zone() - self.daily_forecast = self._weather_data.get_forecast(time_zone, False) - self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) - return self diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index 3a4c3dda507..1d2bf456c9d 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -10,9 +10,12 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY, ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_WIND_BEARING, @@ -34,6 +37,9 @@ FORECAST_MAP = { ATTR_FORECAST_NATIVE_TEMP_LOW: "templow", ATTR_FORECAST_WIND_BEARING: "wind_bearing", ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "wind_gust", + ATTR_FORECAST_CLOUD_COVERAGE: "cloudiness", + ATTR_FORECAST_HUMIDITY: "humidity", } CONDITION_MAP = { diff --git a/homeassistant/components/met_eireann/coordinator.py b/homeassistant/components/met_eireann/coordinator.py new file mode 100644 index 00000000000..fb8c85f6b8d --- /dev/null +++ b/homeassistant/components/met_eireann/coordinator.py @@ -0,0 +1,76 @@ +"""The met_eireann component.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any, Self + +import meteireann + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE +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 DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(minutes=60) + + +class MetEireannWeatherData: + """Keep data for Met Éireann weather entities.""" + + def __init__( + self, config: Mapping[str, Any], weather_data: meteireann.WeatherData + ) -> None: + """Initialise the weather entity data.""" + self._config = config + self._weather_data = weather_data + self.current_weather_data: dict[str, Any] = {} + self.daily_forecast: list[dict[str, Any]] = [] + self.hourly_forecast: list[dict[str, Any]] = [] + + async def fetch_data(self) -> Self: + """Fetch data from API - (current weather and forecast).""" + await self._weather_data.fetching_data() + self.current_weather_data = self._weather_data.get_current_weather() + time_zone = dt_util.get_default_time_zone() + self.daily_forecast = self._weather_data.get_forecast(time_zone, False) + self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) + return self + + +class MetEireannUpdateCoordinator(DataUpdateCoordinator[MetEireannWeatherData]): + """Coordinator for Met Éireann weather 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=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + raw_weather_data = meteireann.WeatherData( + async_get_clientsession(hass), + latitude=config_entry.data[CONF_LATITUDE], + longitude=config_entry.data[CONF_LONGITUDE], + altitude=config_entry.data[CONF_ELEVATION], + ) + self._weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data) + + async def _async_update_data(self) -> MetEireannWeatherData: + """Fetch data from Met Éireann.""" + try: + return await self._weather_data.fetch_data() + except Exception as err: + raise UpdateFailed(f"Update failed: {err}") from err diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 97bbd952740..b6095c174f2 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -1,7 +1,6 @@ """Support for Met Éireann weather service.""" from collections.abc import Mapping -import logging from typing import Any, cast from homeassistant.components.weather import ( @@ -29,10 +28,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import MetEireannWeatherData from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP - -_LOGGER = logging.getLogger(__name__) +from .coordinator import MetEireannWeatherData def format_condition(condition: str | None) -> str | None: @@ -141,6 +138,16 @@ class MetEireannWeather( """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed in native units.""" + return self.coordinator.data.current_weather_data.get("wind_gust") + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage.""" + return self.coordinator.data.current_weather_data.get("cloudiness") + def _forecast(self, hourly: bool) -> list[Forecast]: """Return the forecast array.""" if hourly: diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 5f1d5269538..94918ab4d4f 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -23,7 +23,6 @@ from .const import ( COORDINATOR_RAIN, DOMAIN, PLATFORMS, - UNDO_UPDATE_LISTENER, ) _LOGGER = logging.getLogger(__name__) @@ -64,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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, ) @@ -81,6 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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, ) @@ -104,6 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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, ) @@ -130,10 +132,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.title, ) - undo_listener = entry.add_update_listener(_async_update_listener) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) hass.data[DOMAIN][entry.entry_id] = { - UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, } if coordinator_rain and coordinator_rain.last_update_success: @@ -163,7 +164,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 382a56d50d7..cde2812b059 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -26,7 +26,6 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER] COORDINATOR_FORECAST = "coordinator_forecast" COORDINATOR_RAIN = "coordinator_rain" COORDINATOR_ALERT = "coordinator_alert" -UNDO_UPDATE_LISTENER = "undo_update_listener" ATTRIBUTION = "Data provided by Météo-France" MODEL = "Météo-France mobile API" MANUFACTURER = "Météo-France" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index e2df35f21f3..9b3472e3312 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -6,6 +6,8 @@ import time from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -49,9 +51,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def format_condition(condition: str): +def format_condition(condition: str, force_day: bool = False) -> str: """Return condition from dict CONDITION_MAP.""" - return CONDITION_MAP.get(condition, condition) + mapped_condition = CONDITION_MAP.get(condition, condition) + if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT: + # Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny + return ATTR_CONDITION_SUNNY + return mapped_condition async def async_setup_entry( @@ -212,7 +218,7 @@ class MeteoFranceWeather( forecast["dt"] ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( - forecast["weather12H"]["desc"] + forecast["weather12H"]["desc"], force_day=True ), ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"], ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"], diff --git a/homeassistant/components/meteoclimatic/__init__.py b/homeassistant/components/meteoclimatic/__init__.py index 8c2fb41c634..99f72fe726b 100644 --- a/homeassistant/components/meteoclimatic/__init__.py +++ b/homeassistant/components/meteoclimatic/__init__.py @@ -1,43 +1,15 @@ """Support for Meteoclimatic weather data.""" -import logging - -from meteoclimatic import MeteoclimaticClient -from meteoclimatic.exceptions import MeteoclimaticError - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_STATION_CODE, DOMAIN, PLATFORMS, SCAN_INTERVAL - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, PLATFORMS +from .coordinator import MeteoclimaticUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Meteoclimatic entry.""" - station_code = entry.data[CONF_STATION_CODE] - meteoclimatic_client = MeteoclimaticClient() - - async def async_update_data(): - """Obtain the latest data from Meteoclimatic.""" - try: - data = await hass.async_add_executor_job( - meteoclimatic_client.weather_at_station, station_code - ) - except MeteoclimaticError as err: - raise UpdateFailed(f"Error while retrieving data: {err}") from err - return data.__dict__ - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"Meteoclimatic weather for {entry.title} ({station_code})", - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - ) - + coordinator = MeteoclimaticUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/meteoclimatic/coordinator.py b/homeassistant/components/meteoclimatic/coordinator.py new file mode 100644 index 00000000000..2e9264dd3ef --- /dev/null +++ b/homeassistant/components/meteoclimatic/coordinator.py @@ -0,0 +1,43 @@ +"""Support for Meteoclimatic weather data.""" + +import logging +from typing import Any + +from meteoclimatic import MeteoclimaticClient +from meteoclimatic.exceptions import MeteoclimaticError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_CODE, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class MeteoclimaticUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for Meteoclimatic weather data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self._station_code = entry.data[CONF_STATION_CODE] + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"Meteoclimatic weather for {entry.title} ({self._station_code})", + update_interval=SCAN_INTERVAL, + ) + self._meteoclimatic_client = MeteoclimaticClient() + + async def _async_update_data(self) -> dict[str, Any]: + """Obtain the latest data from Meteoclimatic.""" + try: + data = 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 6e508bd63d8..2d80ccda30c 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -18,12 +18,10 @@ from homeassistant.const import ( 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 ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL +from .coordinator import MeteoclimaticUpdateCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -119,7 +117,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES], @@ -127,13 +125,17 @@ async def async_setup_entry( ) -class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): +class MeteoclimaticSensor( + CoordinatorEntity[MeteoclimaticUpdateCoordinator], SensorEntity +): """Representation of a Meteoclimatic sensor.""" _attr_attribution = ATTRIBUTION def __init__( - self, coordinator: DataUpdateCoordinator, description: SensorEntityDescription + self, + coordinator: MeteoclimaticUpdateCoordinator, + description: SensorEntityDescription, ) -> None: """Initialize the Meteoclimatic sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index fa3b3c92288..ba74cfeca5e 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -8,12 +8,10 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature 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 ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL +from .coordinator import MeteoclimaticUpdateCoordinator def format_condition(condition): @@ -31,12 +29,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic weather platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([MeteoclimaticWeather(coordinator)], False) -class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): +class MeteoclimaticWeather( + CoordinatorEntity[MeteoclimaticUpdateCoordinator], WeatherEntity +): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION @@ -44,7 +44,7 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - def __init__(self, coordinator: DataUpdateCoordinator) -> None: + def __init__(self, coordinator: MeteoclimaticUpdateCoordinator) -> None: """Initialise the weather platform.""" super().__init__(coordinator) self._unique_id = self.coordinator.data["station"].code diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index c6b9f96514b..fc3972eac2a 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -9,6 +9,7 @@ from datapoint.Forecast import Forecast from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, + EntityCategory, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -59,6 +60,7 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( native_attr_name="name", name="Station name", icon="mdi:label-outline", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), MetOfficeSensorEntityDescription( @@ -235,14 +237,13 @@ class MetOfficeCurrentSensor( @property def native_value(self) -> StateType: """Return the state of the sensor.""" - value = get_attribute( - self.coordinator.data.now(), self.entity_description.native_attr_name - ) + native_attr = self.entity_description.native_attr_name - if ( - self.entity_description.native_attr_name == "significantWeatherCode" - and value is not None - ): + if native_attr == "name": + return str(self.coordinator.data.name) + + value = get_attribute(self.coordinator.data.now(), native_attr) + if native_attr == "significantWeatherCode" and value is not None: value = CONDITION_MAP.get(value) return value diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 9b9ec81bea9..173865195df 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -3,20 +3,23 @@ from __future__ import annotations from aiohttp import ClientError, ClientResponseError +from pymiele import MieleAPI from homeassistant.const import Platform 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.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import DOMAIN from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -29,6 +32,15 @@ PLATFORMS: list[Platform] = [ Platform.VACUUM, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up service actions.""" + await async_setup_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: """Set up Miele from a config entry.""" @@ -55,7 +67,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> boo ) from err # Setup MieleAPI and coordinator for data fetch - coordinator = MieleDataUpdateCoordinator(hass, auth) + api = MieleAPI(auth) + coordinator = MieleDataUpdateCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py index d3c7dbba12b..191cd9a0454 100644 --- a/homeassistant/components/miele/config_flow.py +++ b/homeassistant/components/miele/config_flow.py @@ -26,14 +26,6 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - @property - def extra_authorize_data(self) -> dict: - """Extra data that needs to be appended to the authorize url.""" - # "vg" is mandatory but the value doesn't seem to matter - return { - "vg": "sv-SE", - } - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index fd2f8631cd2..3b5b13398a5 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -431,6 +431,16 @@ DISHWASHER_PROGRAM_ID: dict[int, str] = { 38: "quick_power_wash", 42: "tall_items", 44: "power_wash", + 200: "eco", + 202: "automatic", + 203: "comfort_wash", + 204: "power_wash", + 205: "intensive", + 207: "extra_quiet", + 209: "comfort_wash_plus", + 210: "gentle", + 214: "maintenance", + 215: "rinse_salt", } TUMBLE_DRYER_PROGRAM_ID: dict[int, str] = { -1: "no_program", # Extrapolated from other device types. @@ -1314,10 +1324,11 @@ class PlatePowerStep(MieleEnum): plate_step_11 = 11 plate_step_12 = 12 plate_step_13 = 13 - plate_step_14 = 4 + plate_step_14 = 14 plate_step_15 = 15 plate_step_16 = 16 plate_step_17 = 17 plate_step_18 = 18 plate_step_boost = 117, 118, 218 + plate_step_boost_2 = 217 missing2none = -9999 diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index 27456ffe04c..98f5c9f8b1c 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -8,13 +8,12 @@ from dataclasses import dataclass from datetime import timedelta import logging -from pymiele import MieleAction, MieleDevice +from pymiele import MieleAction, MieleAPI, MieleDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .api import AsyncConfigEntryAuth from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -42,12 +41,14 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): def __init__( self, hass: HomeAssistant, - api: AsyncConfigEntryAuth, + config_entry: MieleConfigEntry, + api: MieleAPI, ) -> None: """Initialize the Miele data coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=120), ) diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index f9ed4f0bf48..57c10f6f7bd 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -1,12 +1,11 @@ """Entity base class for the Miele integration.""" -from pymiele import MieleAction, MieleDevice +from pymiele import MieleAction, MieleAPI, MieleDevice from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import AsyncConfigEntryAuth from .const import DEVICE_TYPE_TAGS, DOMAIN, MANUFACTURER, MieleAppliance, StateStatus from .coordinator import MieleDataUpdateCoordinator @@ -16,6 +15,11 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): _attr_has_entity_name = True + @staticmethod + def get_unique_id(device_id: str, description: EntityDescription) -> str: + """Generate a unique ID for the entity.""" + return f"{device_id}-{description.key}" + def __init__( self, coordinator: MieleDataUpdateCoordinator, @@ -26,7 +30,7 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): super().__init__(coordinator) self._device_id = device_id self.entity_description = description - self._attr_unique_id = f"{device_id}-{description.key}" + self._attr_unique_id = MieleEntity.get_unique_id(device_id, description) device = self.device appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type)) @@ -52,7 +56,7 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): return self.coordinator.data.actions[self._device_id] @property - def api(self) -> AsyncConfigEntryAuth: + def api(self) -> MieleAPI: """Return the api object.""" return self.coordinator.api diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 44b51a67c24..a5dbeb4ec2d 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -76,7 +76,8 @@ "plate_step_16": "mdi:circle-slice-7", "plate_step_17": "mdi:circle-slice-8", "plate_step_18": "mdi:circle-slice-8", - "plate_step_boost": "mdi:alpha-b-circle-outline" + "plate_step_boost": "mdi:alpha-b-circle-outline", + "plate_step_boost_2": "mdi:alpha-b-circle" } }, "program_type": { @@ -103,5 +104,16 @@ "default": "mdi:snowflake" } } + }, + "services": { + "get_programs": { + "service": "mdi:stack-overflow" + }, + "set_program": { + "service": "mdi:arrow-right-circle-outline" + }, + "set_program_oven": { + "service": "mdi:arrow-right-circle-outline" + } } } diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index c9a20e977f9..63ace343dc8 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -8,7 +8,7 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "bronze", - "requirements": ["pymiele==0.5.2"], + "requirements": ["pymiele==0.5.4"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] } diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index ff72b791735..cc108841aae 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass import logging from typing import Final, cast -from pymiele import MieleDevice +from pymiele import MieleDevice, MieleTemperature from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,10 +25,13 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( + DISABLED_TEMP_ENTITIES, + DOMAIN, STATE_PROGRAM_ID, STATE_PROGRAM_PHASE, STATE_STATUS_TAGS, @@ -45,8 +48,6 @@ PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) -DISABLED_TEMPERATURE = -32768 - DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { @@ -75,12 +76,25 @@ def _convert_duration(value_list: list[int]) -> int | None: return value_list[0] * 60 + value_list[1] if value_list else None +def _convert_temperature( + value_list: list[MieleTemperature], index: int +) -> float | None: + """Convert temperature object to readable value.""" + if index >= len(value_list): + return None + raw_value = cast(int, value_list[index].temperature) / 100.0 + if raw_value in DISABLED_TEMP_ENTITIES: + return None + return raw_value + + @dataclass(frozen=True, kw_only=True) class MieleSensorDescription(SensorEntityDescription): """Class describing Miele sensor entities.""" value_fn: Callable[[MieleDevice], StateType] - zone: int = 1 + zone: int | None = None + unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None @dataclass @@ -404,32 +418,20 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( ), description=MieleSensorDescription( key="state_temperature_1", + zone=1, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: cast(int, value.state_temperatures[0].temperature) - / 100.0, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 0), ), ), MieleSensorDefinition( types=( - MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, - MieleAppliance.OVEN, - MieleAppliance.OVEN_MICROWAVE, - MieleAppliance.DISH_WARMER, - MieleAppliance.STEAM_OVEN, - MieleAppliance.MICROWAVE, - MieleAppliance.FRIDGE, - MieleAppliance.FREEZER, MieleAppliance.FRIDGE_FREEZER, - MieleAppliance.STEAM_OVEN_COMBI, MieleAppliance.WINE_CABINET, MieleAppliance.WINE_CONDITIONING_UNIT, MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, - MieleAppliance.STEAM_OVEN_MICRO, - MieleAppliance.DIALOG_OVEN, MieleAppliance.WINE_CABINET_FREEZER, - MieleAppliance.STEAM_OVEN_MK2, ), description=MieleSensorDescription( key="state_temperature_2", @@ -438,7 +440,24 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( translation_key="temperature_zone_2", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator] + value_fn=lambda value: _convert_temperature(value.state_temperatures, 1), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleSensorDescription( + key="state_temperature_3", + zone=3, + device_class=SensorDeviceClass.TEMPERATURE, + translation_key="temperature_zone_3", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 2), ), ), MieleSensorDefinition( @@ -454,11 +473,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast( - int, value.state_core_target_temperature[0].temperature - ) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_target_temperature, 0 ), ), ), @@ -479,9 +495,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_target_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_target_temperature, 0 ), ), ), @@ -497,9 +512,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_core_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_temperature, 0 ), ), ), @@ -518,6 +532,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.ENUM, options=sorted(PlatePowerStep.keys()), value_fn=lambda value: None, + unique_id_fn=lambda device_id, + description: f"{device_id}-{description.key}-{description.zone}", ), ) for i in range(1, 7) @@ -539,6 +555,16 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( options=sorted(StateDryingStep.keys()), ), ), + MieleSensorDefinition( + types=(MieleAppliance.ROBOT_VACUUM_CLEANER,), + description=MieleSensorDescription( + key="state_battery", + value_fn=lambda value: value.state_battery_level, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + ), + ), ) @@ -549,10 +575,52 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data - added_devices: set[str] = set() + added_devices: set[str] = set() # device_id + added_entities: set[str] = set() # unique_id - def _async_add_new_devices() -> None: - nonlocal added_devices + def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]: + """Get the entity class for the sensor.""" + return { + "state_status": MieleStatusSensor, + "state_program_id": MieleProgramIdSensor, + "state_program_phase": MielePhaseSensor, + "state_plate_step": MielePlateSensor, + }.get(definition.description.key, MieleSensor) + + def _is_entity_registered(unique_id: str) -> bool: + """Check if the entity is already registered.""" + entity_registry = er.async_get(hass) + return any( + entry.platform == DOMAIN and entry.unique_id == unique_id + for entry in entity_registry.entities.values() + ) + + def _is_sensor_enabled( + definition: MieleSensorDefinition, + device: MieleDevice, + unique_id: str, + ) -> bool: + """Check if the sensor is enabled.""" + if ( + definition.description.device_class == SensorDeviceClass.TEMPERATURE + and definition.description.value_fn(device) is None + and definition.description.zone != 1 + ): + # all appliances supporting temperature have at least zone 1, for other zones + # don't create entity if API signals that datapoint is disabled, unless the sensor + # already appeared in the past (= it provided a valid value) + return _is_entity_registered(unique_id) + if ( + definition.description.key == "state_plate_step" + and definition.description.zone is not None + and definition.description.zone > _get_plate_count(device.tech_type) + ): + # don't create plate entity if not expected by the appliance tech type + return False + return True + + def _async_add_devices() -> None: + nonlocal added_devices, added_entities entities: list = [] entity_class: type[MieleSensor] new_devices_set, current_devices = coordinator.async_add_devices(added_devices) @@ -560,40 +628,35 @@ async def async_setup_entry( for device_id, device in coordinator.data.devices.items(): for definition in SENSOR_TYPES: - if ( - device_id in new_devices_set - and device.device_type in definition.types - ): - match definition.description.key: - case "state_status": - entity_class = MieleStatusSensor - case "state_program_id": - entity_class = MieleProgramIdSensor - case "state_program_phase": - entity_class = MielePhaseSensor - case "state_plate_step": - entity_class = MielePlateSensor - case _: - entity_class = MieleSensor - if ( - definition.description.device_class - == SensorDeviceClass.TEMPERATURE - and definition.description.value_fn(device) - == DISABLED_TEMPERATURE / 100 - ) or ( - definition.description.key == "state_plate_step" - and definition.description.zone - > _get_plate_count(device.tech_type) - ): - # Don't create entity if API signals that datapoint is disabled - continue - entities.append( - entity_class(coordinator, device_id, definition.description) + # device is not supported, skip + if device.device_type not in definition.types: + continue + + entity_class = _get_entity_class(definition) + unique_id = ( + definition.description.unique_id_fn( + device_id, definition.description ) + if definition.description.unique_id_fn is not None + else MieleEntity.get_unique_id(device_id, definition.description) + ) + + # entity was already added, skip + if device_id not in new_devices_set and unique_id in added_entities: + continue + + # sensors is not enabled, skip + if not _is_sensor_enabled(definition, device, unique_id): + continue + + added_entities.add(unique_id) + entities.append( + entity_class(coordinator, device_id, definition.description) + ) async_add_entities(entities) - config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) - _async_add_new_devices() + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_devices)) + _async_add_devices() APPLIANCE_ICONS = { @@ -631,6 +694,17 @@ class MieleSensor(MieleEntity, SensorEntity): entity_description: MieleSensorDescription + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, description) + if description.unique_id_fn is not None: + self._attr_unique_id = description.unique_id_fn(device_id, description) + @property def native_value(self) -> StateType: """Return the state of the sensor.""" @@ -642,16 +716,6 @@ class MielePlateSensor(MieleSensor): entity_description: MieleSensorDescription - def __init__( - self, - coordinator: MieleDataUpdateCoordinator, - device_id: str, - description: MieleSensorDescription, - ) -> None: - """Initialize the plate sensor.""" - super().__init__(coordinator, device_id, description) - self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" - @property def native_value(self) -> StateType: """Return the state of the plate sensor.""" @@ -662,12 +726,12 @@ class MielePlateSensor(MieleSensor): cast( int, self.device.state_plate_step[ - self.entity_description.zone - 1 + cast(int, self.entity_description.zone) - 1 ].value_raw, ) ).name if self.device.state_plate_step - else PlatePowerStep.plate_step_0 + else PlatePowerStep.plate_step_0.name ) diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py new file mode 100644 index 00000000000..517b489173d --- /dev/null +++ b/homeassistant/components/miele/services.py @@ -0,0 +1,238 @@ +"""Services for Miele integration.""" + +from datetime import timedelta +import logging +from typing import cast + +import aiohttp +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .const import DOMAIN +from .coordinator import MieleConfigEntry + +ATTR_PROGRAM_ID = "program_id" +ATTR_DURATION = "duration" + + +SERVICE_SET_PROGRAM = "set_program" +SERVICE_SET_PROGRAM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM_ID): cv.positive_int, + }, +) + +SERVICE_SET_PROGRAM_OVEN = "set_program_oven" +SERVICE_SET_PROGRAM_OVEN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM_ID): cv.positive_int, + vol.Optional(ATTR_TEMPERATURE): cv.positive_int, + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)), + ), + }, +) + +SERVICE_GET_PROGRAMS = "get_programs" +SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + }, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry: + """Extract config entry from the service call.""" + hass = service_call.hass + target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entries: list[MieleConfigEntry] = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + return target_entries[0] + + +async def _get_serial_number(call: ServiceCall) -> str: + """Extract the serial number from the device identifier.""" + + device_reg = dr.async_get(call.hass) + device = call.data[ATTR_DEVICE_ID] + device_entry = device_reg.async_get(device) + serial_number = next( + ( + identifier[1] + for identifier in cast(dr.DeviceEntry, device_entry).identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if serial_number is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + return serial_number + + +async def set_program(call: ServiceCall) -> None: + """Set a program on a Miele appliance.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + + serial_number = await _get_serial_number(call) + data = {"programId": call.data[ATTR_PROGRAM_ID]} + try: + await api.set_program(serial_number, data) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_program_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + +async def set_program_oven(call: ServiceCall) -> None: + """Set a program on a Miele oven.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + + serial_number = await _get_serial_number(call) + data = {"programId": call.data[ATTR_PROGRAM_ID]} + if call.data.get(ATTR_DURATION) is not None: + td = call.data[ATTR_DURATION] + data["duration"] = [ + td.seconds // 3600, # hours + (td.seconds // 60) % 60, # minutes + ] + if call.data.get(ATTR_TEMPERATURE) is not None: + data["temperature"] = call.data[ATTR_TEMPERATURE] + try: + await api.set_program(serial_number, data) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_program_oven_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + +async def get_programs(call: ServiceCall) -> ServiceResponse: + """Get available programs from appliance.""" + + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + serial_number = await _get_serial_number(call) + + try: + programs = await api.get_programs(serial_number) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_programs_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + return { + "programs": [ + { + "program_id": item["programId"], + "program": item["program"].strip(), + "parameters": ( + { + "temperature": ( + { + "min": item["parameters"]["temperature"]["min"], + "max": item["parameters"]["temperature"]["max"], + "step": item["parameters"]["temperature"]["step"], + "mandatory": item["parameters"]["temperature"][ + "mandatory" + ], + } + if "temperature" in item["parameters"] + else {} + ), + "duration": ( + { + "min": { + "hours": item["parameters"]["duration"]["min"][0], + "minutes": item["parameters"]["duration"]["min"][1], + }, + "max": { + "hours": item["parameters"]["duration"]["max"][0], + "minutes": item["parameters"]["duration"]["max"][1], + }, + "mandatory": item["parameters"]["duration"][ + "mandatory" + ], + } + if "duration" in item["parameters"] + else {} + ), + } + if item.get("parameters") + else {} + ), + } + for item in programs + ], + } + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM, + set_program, + SERVICE_SET_PROGRAM_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_OVEN, + set_program_oven, + SERVICE_SET_PROGRAM_OVEN_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PROGRAMS, + get_programs, + SERVICE_GET_PROGRAMS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml new file mode 100644 index 00000000000..87114343ad1 --- /dev/null +++ b/homeassistant/components/miele/services.yaml @@ -0,0 +1,55 @@ +# Services descriptions for Miele integration + +get_programs: + fields: + device_id: + selector: + device: + integration: miele + required: true + +set_program: + fields: + device_id: + selector: + device: + integration: miele + required: true + program_id: + required: true + selector: + number: + min: 0 + max: 99999 + mode: box + example: 24 + +set_program_oven: + fields: + device_id: + selector: + device: + integration: miele + required: true + program_id: + required: true + selector: + number: + min: 0 + max: 99999 + mode: box + example: 24 + temperature: + required: false + selector: + number: + min: 30 + max: 300 + unit_of_measurement: "°C" + mode: box + example: 180 + duration: + required: false + selector: + duration: + example: 1:15:00 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 97035da6d5f..cb9861e0246 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -203,27 +203,28 @@ "plate": { "name": "Plate {plate_no}", "state": { - "power_step_0": "0", - "power_step_warm": "Warming", - "power_step_1": "1", - "power_step_2": "1\u2022", - "power_step_3": "2", - "power_step_4": "2\u2022", - "power_step_5": "3", - "power_step_6": "3\u2022", - "power_step_7": "4", - "power_step_8": "4\u2022", - "power_step_9": "5", - "power_step_10": "5\u2022", - "power_step_11": "6", - "power_step_12": "6\u2022", - "power_step_13": "7", - "power_step_14": "7\u2022", - "power_step_15": "8", - "power_step_16": "8\u2022", - "power_step_17": "9", - "power_step_18": "9\u2022", - "power_step_boost": "Boost" + "plate_step_0": "0", + "plate_step_warm": "Warming", + "plate_step_1": "1", + "plate_step_2": "1\u2022", + "plate_step_3": "2", + "plate_step_4": "2\u2022", + "plate_step_5": "3", + "plate_step_6": "3\u2022", + "plate_step_7": "4", + "plate_step_8": "4\u2022", + "plate_step_9": "5", + "plate_step_10": "5\u2022", + "plate_step_11": "6", + "plate_step_12": "6\u2022", + "plate_step_13": "7", + "plate_step_14": "7\u2022", + "plate_step_15": "8", + "plate_step_16": "8\u2022", + "plate_step_17": "9", + "plate_step_18": "9\u2022", + "plate_step_boost": "Boost", + "plate_step_boost_2": "Boost 2" } }, "drying_step": { @@ -462,8 +463,8 @@ "chicken_tikka_masala_with_rice": "Chicken Tikka Masala with rice", "chicken_whole": "Chicken", "chinese_cabbage_cut": "Chinese cabbage (cut)", - "chocolate_hazlenut_cake_one_large": "Chocolate hazlenut cake (one large)", - "chocolate_hazlenut_cake_several_small": "Chocolate hazlenut cake (several small)", + "chocolate_hazlenut_cake_one_large": "Chocolate hazelnut cake (one large)", + "chocolate_hazlenut_cake_several_small": "Chocolate hazelnut cake (several small)", "chongming_rapid_steam_cooking": "Chongming (rapid steam cooking)", "chongming_steam_cooking": "Chongming (steam cooking)", "choux_buns": "Choux buns", @@ -485,6 +486,8 @@ "cook_bacon": "Cook bacon", "biscuits_short_crust_pastry_1_tray": "Biscuits, short crust pastry (1 tray)", "biscuits_short_crust_pastry_2_trays": "Biscuits, short crust pastry (2 trays)", + "comfort_wash": "Comfort wash", + "comfort_wash_plus": "Comfort wash plus", "cool_air": "Cool air", "corn_on_the_cob": "Corn on the cob", "cottons": "Cottons", @@ -698,7 +701,7 @@ "parsnip_cut_into_batons": "Parsnip (cut into batons)", "parsnip_diced": "Parsnip (diced)", "parsnip_sliced": "Parsnip (sliced)", - "pasta_paela": "Pasta/Paela", + "pasta_paela": "Pasta/paella", "pears_halved": "Pears (halved)", "pears_quartered": "Pears (quartered)", "pears_to_cook_large_halved": "Pears to cook (large, halved)", @@ -827,6 +830,7 @@ "rice_pudding_steam_cooking": "Rice pudding (steam cooking)", "rinse": "Rinse", "rinse_out_lint": "Rinse out lint", + "rinse_salt": "Rinse salt", "risotto": "Risotto", "ristretto": "Ristretto", "roast_beef_low_temperature_cooking": "Roast beef (low temperature cooking)", @@ -1059,8 +1063,68 @@ "config_entry_not_ready": { "message": "Error while loading the integration." }, + "invalid_target": { + "message": "Invalid device targeted." + }, + "get_programs_error": { + "message": "'Get programs' action failed: {status} / {message}" + }, + "set_program_error": { + "message": "'Set program' action failed: {status} / {message}" + }, + "set_program_oven_error": { + "message": "'Set program on oven' action failed: {status} / {message}" + }, "set_state_error": { "message": "Failed to set state for {entity}." } + }, + "services": { + "get_programs": { + "name": "Get programs", + "description": "Returns a list of available programs.", + "fields": { + "device_id": { + "description": "[%key:component::miele::services::set_program::fields::device_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::device_id::name%]" + } + } + }, + "set_program": { + "name": "Set program", + "description": "Sets and starts a program on the appliance.", + "fields": { + "device_id": { + "description": "The target device for this action.", + "name": "Device" + }, + "program_id": { + "description": "The ID of the program to set.", + "name": "Program ID" + } + } + }, + "set_program_oven": { + "name": "Set program on oven", + "description": "[%key:component::miele::services::set_program::description%]", + "fields": { + "device_id": { + "description": "[%key:component::miele::services::set_program::fields::device_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::device_id::name%]" + }, + "program_id": { + "description": "[%key:component::miele::services::set_program::fields::program_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::program_id::name%]" + }, + "temperature": { + "description": "The target temperature for the oven program.", + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "duration": { + "description": "The duration for the oven program.", + "name": "[%key:component::sensor::entity_component::duration::name%]" + } + } + } } } diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py index 29a89e39bdb..999ceac5cce 100644 --- a/homeassistant/components/miele/vacuum.py +++ b/homeassistant/components/miele/vacuum.py @@ -87,7 +87,6 @@ class MieleVacuumStateCode(MieleEnum): SUPPORTED_FEATURES = ( VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.START | VacuumEntityFeature.STOP @@ -174,11 +173,6 @@ class MieleVacuum(MieleEntity, StateVacuumEntity): MieleVacuumStateCode(self.device.state_program_phase).value ) - @property - def battery_level(self) -> int | None: - """Return the battery level.""" - return self.device.state_battery_level - @property def fan_speed(self) -> str | None: """Return the fan speed.""" diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index c68b13eeca8..a94d3b4b64e 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -83,12 +83,12 @@ class MikrotikData: @property def arp_enabled(self) -> bool: """Return arp_ping option setting.""" - return self.config_entry.options.get(CONF_ARP_PING, False) + return self.config_entry.options.get(CONF_ARP_PING, False) # type: ignore[no-any-return] @property def force_dhcp(self) -> bool: """Return force_dhcp option setting.""" - return self.config_entry.options.get(CONF_FORCE_DHCP, False) + return self.config_entry.options.get(CONF_FORCE_DHCP, False) # type: ignore[no-any-return] def get_info(self, param: str) -> str: """Return device model name.""" diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 246ea778916..ce258712090 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -43,6 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: historic_data_coordinator = MillHistoricDataUpdateCoordinator( hass, + entry, mill_data_connection=mill_data_connection, ) historic_data_coordinator.async_add_listener(lambda: None) diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index a701acb8ddb..ea1295376ae 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -60,6 +60,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, *, mill_data_connection: Mill, ) -> None: @@ -70,6 +71,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name="MillHistoricDataUpdateCoordinator", + config_entry=config_entry, ) async def _async_update_data(self): diff --git a/homeassistant/components/mjpeg/strings.json b/homeassistant/components/mjpeg/strings.json index 0e1e71fd82c..ed53f6bcdc9 100644 --- a/homeassistant/components/mjpeg/strings.json +++ b/homeassistant/components/mjpeg/strings.json @@ -6,7 +6,7 @@ "mjpeg_url": "MJPEG URL", "name": "[%key:common::config_flow::data::name%]", "password": "[%key:common::config_flow::data::password%]", - "still_image_url": "Still Image URL", + "still_image_url": "Still image URL", "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 25c35b3e87e..1dab894b2f6 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,7 +25,6 @@ ATTR_APP_DATA = "app_data" ATTR_APP_ID = "app_id" ATTR_APP_NAME = "app_name" ATTR_APP_VERSION = "app_version" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_DEVICE_NAME = "device_name" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 28d1be24587..a7e2cd51a65 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity @@ -24,6 +23,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_hub from .const import ( + _LOGGER, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_SLAVE_COUNT, @@ -32,8 +32,6 @@ from .const import ( from .entity import BasePlatform from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index be10a9495c6..f8e7dca245a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import struct from typing import Any, cast @@ -44,6 +43,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .const import ( + _LOGGER, CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_WRITE_COIL, @@ -104,8 +104,6 @@ from .const import ( from .entity import BaseStructPlatform from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY = { @@ -469,9 +467,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): async def _async_update(self) -> None: """Update Target & Current Temperature.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval - self._attr_target_temperature = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register[ @@ -495,6 +490,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if hvac_mode == value: self._attr_hvac_mode = mode break + else: + # since there are no hvac_mode_register, this + # integration should not touch the attr. + # However it lacks in the climate component. + self._attr_hvac_mode = HVACMode.AUTO # Read the HVAC action register if defined if self._hvac_action_register is not None: diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 068a46b1f81..dafc604e781 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -1,6 +1,7 @@ """Constants used in modbus integration.""" from enum import Enum +import logging from homeassistant.const import ( CONF_ADDRESS, @@ -96,6 +97,7 @@ CONF_VIRTUAL_COUNT = "virtual_count" CONF_WRITE_TYPE = "write_type" CONF_ZERO_SUPPRESS = "zero_suppress" +DEVICE_ID = "device_id" RTUOVERTCP = "rtuovertcp" SERIAL = "serial" TCP = "tcp" @@ -177,3 +179,5 @@ LIGHT_MAX_BRIGHTNESS = 255 LIGHT_MODBUS_SCALE_MIN = 0 LIGHT_MODBUS_SCALE_MAX = 100 LIGHT_MODBUS_INVALID_VALUE = 0xFFFF + +_LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 5e7b008ff7c..23a09431072 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -123,8 +123,6 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): async def _async_update(self) -> None: """Update the state of the cover.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval result = await self._hub.async_pb_call( self._slave, self._address, 1, self._input_type ) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 53c3e8f8709..689d882a2f3 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -3,10 +3,8 @@ from __future__ import annotations from abc import abstractmethod -import asyncio from collections.abc import Callable from datetime import datetime, timedelta -import logging import struct from typing import Any, cast @@ -29,10 +27,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity -from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from .const import ( + _LOGGER, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -68,8 +67,6 @@ from .const import ( ) from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - class BasePlatform(Entity): """Base for readonly platforms.""" @@ -94,7 +91,6 @@ class BasePlatform(Entity): self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._cancel_timer: Callable[[], None] | None = None self._cancel_call: Callable[[], None] | None = None - self._attr_unique_id = entry.get(CONF_UNIQUE_ID) self._attr_name = entry[CONF_NAME] self._attr_device_class = entry.get(CONF_DEVICE_CLASS) @@ -111,29 +107,39 @@ class BasePlatform(Entity): self._max_value = get_optional_numeric_config(CONF_MAX_VALUE) self._nan_value = entry.get(CONF_NAN_VALUE) self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS) - self._update_lock = asyncio.Lock() @abstractmethod async def _async_update(self) -> None: """Virtual function to be overwritten.""" - async def async_update(self, now: datetime | None = None) -> None: + async def async_update(self) -> None: """Update the entity state.""" - async with self._update_lock: - await self._async_update() + if self._cancel_call: + self._cancel_call() + await self.async_local_update() + + async def async_local_update(self, now: datetime | None = None) -> None: + """Update the entity state.""" + await self._async_update() + self.async_write_ha_state() + if self._scan_interval > 0: + self._cancel_call = async_call_later( + self.hass, + timedelta(seconds=self._scan_interval), + self.async_local_update, + ) async def _async_update_write_state(self) -> None: """Update the entity state and write it to the state machine.""" - await self.async_update() - self.async_write_ha_state() + if self._cancel_call: + self._cancel_call() + self._cancel_call = None + await self.async_local_update() async def _async_update_if_not_in_progress( self, now: datetime | None = None ) -> None: """Update the entity state if not already in progress.""" - if self._update_lock.locked(): - _LOGGER.debug("Update for entity %s is already in progress", self.name) - return await self._async_update_write_state() @callback @@ -141,12 +147,9 @@ class BasePlatform(Entity): """Remote start entity.""" self._async_cancel_update_polling() self._async_schedule_future_update(0.1) - if self._scan_interval > 0: - self._cancel_timer = async_track_time_interval( - self.hass, - self._async_update_if_not_in_progress, - timedelta(seconds=self._scan_interval), - ) + self._cancel_call = async_call_later( + self.hass, timedelta(seconds=0.1), self.async_local_update + ) self._attr_available = True self.async_write_ha_state() @@ -179,9 +182,20 @@ class BasePlatform(Entity): self._attr_available = False self.async_write_ha_state() + async def async_await_connection(self, _now: Any) -> None: + """Wait for first connect.""" + await self._hub.event_connected.wait() + self.async_run() + async def async_base_added_to_hass(self) -> None: """Handle entity which will be added.""" - self.async_run() + self.async_on_remove( + async_call_later( + self.hass, + self._hub.config_delay + 0.1, + self.async_await_connection, + ) + ) self.async_on_remove( async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_hold) ) @@ -382,8 +396,6 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): async def _async_update(self) -> None: """Update the entity state.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval if not self._verify_active: self._attr_available = True return diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index c025eefe0e4..7b1035c702b 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from homeassistant.components.light import ( @@ -35,7 +34,6 @@ from .entity import BaseSwitch from .modbus import ModbusHub PARALLEL_UPDATES = 1 -_LOGGER = logging.getLogger(__name__) async def async_setup_platform( diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 555026b4bda..32a043c4379 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.9.2"] + "requirements": ["pymodbus==3.11.1"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 006ef504590..f8604efdc2f 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -4,8 +4,6 @@ from __future__ import annotations import asyncio from collections import namedtuple -from collections.abc import Callable -import logging from typing import Any from pymodbus.client import ( @@ -29,15 +27,15 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall 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.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import ( + _LOGGER, ATTR_ADDRESS, ATTR_HUB, ATTR_SLAVE, @@ -57,6 +55,7 @@ from .const import ( CONF_PARITY, CONF_STOPBITS, DEFAULT_HUB, + DEVICE_ID, MODBUS_DOMAIN as DOMAIN, PLATFORMS, RTUOVERTCP, @@ -70,9 +69,9 @@ from .const import ( ) from .validators import check_config -_LOGGER = logging.getLogger(__name__) DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN) +PRIMARY_RECONNECT_DELAY = 60 ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024 RunEntry = namedtuple("RunEntry", "attr func value_attr_name") # noqa: PYI024 @@ -172,7 +171,7 @@ async def async_modbus_setup( async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" - slave = 0 + slave = 1 if ATTR_UNIT in service.data: slave = int(float(service.data[ATTR_UNIT])) @@ -195,7 +194,7 @@ async def async_modbus_setup( async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" - slave = 0 + slave = 1 if ATTR_UNIT in service.data: slave = int(float(service.data[ATTR_UNIT])) if ATTR_SLAVE in service.data: @@ -254,14 +253,15 @@ class ModbusHub: self._client: ( AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None ) = None - self._async_cancel_listener: Callable[[], None] | None = None - self._in_error = False self._lock = asyncio.Lock() + self.event_connected = asyncio.Event() self.hass = hass self.name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] - self._config_delay = client_config[CONF_DELAY] + self.config_delay = client_config[CONF_DELAY] self._pb_request: dict[str, RunEntry] = {} + self._connect_task: asyncio.Task + self._last_log_error: str = "" self._pb_class = { SERIAL: AsyncModbusSerialClient, TCP: AsyncModbusTcpClient, @@ -302,32 +302,41 @@ class ModbusHub: else: self._msg_wait = 0 - def _log_error(self, text: str, error_state: bool = True) -> None: + def _log_error(self, text: str) -> None: + if text == self._last_log_error: + return + self._last_log_error = text log_text = f"Pymodbus: {self.name}: {text}" - if self._in_error: - _LOGGER.debug(log_text) - else: - _LOGGER.error(log_text) - self._in_error = error_state + _LOGGER.error(log_text) async def async_pb_connect(self) -> None: """Connect to device, async.""" - async with self._lock: - try: - await self._client.connect() # type: ignore[union-attr] - except ModbusException as exception_error: - err = f"{self.name} connect failed, retry in pymodbus ({exception_error!s})" - self._log_error(err, error_state=False) - return - message = f"modbus {self.name} communication open" - _LOGGER.info(message) + while True: + async with self._lock: + try: + if await self._client.connect(): # type: ignore[union-attr] + _LOGGER.info(f"modbus {self.name} communication open") + break + except ModbusException as exception_error: + self._log_error( + f"{self.name} connect failed, please check your configuration ({exception_error!s})" + ) + _LOGGER.info( + f"modbus {self.name} connect NOT a success ! retrying in {PRIMARY_RECONNECT_DELAY} seconds" + ) + await asyncio.sleep(PRIMARY_RECONNECT_DELAY) + + if self.config_delay: + await asyncio.sleep(self.config_delay) + self.config_delay = 0 + self.event_connected.set() async def async_setup(self) -> bool: """Set up pymodbus client.""" try: self._client = self._pb_class[self._config_type](**self._pb_params) except ModbusException as exception_error: - self._log_error(str(exception_error), error_state=False) + self._log_error(str(exception_error)) return False for entry in PB_CALL: @@ -336,23 +345,11 @@ class ModbusHub: entry.attr, func, entry.value_attr_name ) - self.hass.async_create_background_task( + self._connect_task = self.hass.async_create_background_task( self.async_pb_connect(), "modbus-connect" ) - - # Start counting down to allow modbus requests. - if self._config_delay: - self._async_cancel_listener = async_call_later( - self.hass, self._config_delay, self.async_end_delay - ) return True - @callback - def async_end_delay(self, args: Any) -> None: - """End startup delay.""" - self._async_cancel_listener = None - self._config_delay = 0 - async def async_restart(self) -> None: """Reconnect client.""" if self._client: @@ -362,9 +359,9 @@ class ModbusHub: async def async_close(self) -> None: """Disconnect client.""" - if self._async_cancel_listener: - self._async_cancel_listener() - self._async_cancel_listener = None + if not self._connect_task.done(): + self._connect_task.cancel() + async with self._lock: if self._client: try: @@ -381,7 +378,7 @@ class ModbusHub: ) -> ModbusPDU | None: """Call sync. pymodbus.""" kwargs: dict[str, Any] = ( - {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} + {DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1} ) entry = self._pb_request[use_call] @@ -410,7 +407,6 @@ class ModbusHub: error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" self._log_error(error) return None - self._in_error = False return result async def async_pb_call( @@ -421,8 +417,6 @@ class ModbusHub: use_call: str, ) -> ModbusPDU | None: """Convert async to sync pymodbus call.""" - if self._config_delay: - return None async with self._lock: if not self._client: return None diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 490aece587c..a11e25b4dd4 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from homeassistant.components.sensor import ( @@ -26,12 +25,10 @@ from homeassistant.helpers.update_coordinator import ( ) from . import get_hub -from .const import CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT +from .const import _LOGGER, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT from .entity import BaseStructPlatform from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 @@ -109,8 +106,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): async def _async_update(self) -> None: """Update the state of the sensor.""" - # remark "now" is a dummy parameter to avoid problems with - # async_track_time_interval self._cancel_call = None raw_result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 7d1578558b0..dd71785740b 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -50,7 +50,7 @@ }, "stop": { "name": "[%key:common::action::stop%]", - "description": "Stops modbus hub.", + "description": "Stops a Modbus hub.", "fields": { "hub": { "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", @@ -60,7 +60,7 @@ }, "restart": { "name": "[%key:common::action::restart%]", - "description": "Restarts modbus hub (if running stop then start).", + "description": "Restarts a Modbus hub (if running, stops then starts).", "fields": { "hub": { "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", @@ -70,14 +70,6 @@ } }, "issues": { - "removed_lazy_error_count": { - "title": "{config_key} configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" - }, - "deprecated_retries": { - "title": "{config_key} configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nThe maximum number of retries is now fixed to 3." - }, "missing_modbus_name": { "title": "Modbus entry with host {sub_2} missing name", "description": "Please add `{sub_1}` key to the {integration} entry with host `{sub_2}` in your configuration.yaml file and restart Home Assistant to fix this issue\n\n. `{sub_1}: {sub_3}` have been added." diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index de8e4b2f73c..db901511d5f 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from phone_modem import PhoneModem -from homeassistant.components.sensor import SensorEntity +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 @@ -40,7 +40,7 @@ async def async_setup_entry( ) -class ModemCalleridSensor(SensorEntity): +class ModemCalleridSensor(RestoreSensor): """Implementation of USB modem caller ID sensor.""" _attr_should_poll = False @@ -62,9 +62,21 @@ class ModemCalleridSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Call when the modem sensor is added to Home Assistant.""" - self.api.registercallback(self._async_incoming_call) await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_extra_state_attributes[CID.CID_NAME] = last_state.attributes.get( + CID.CID_NAME, "" + ) + self._attr_extra_state_attributes[CID.CID_NUMBER] = ( + last_state.attributes.get(CID.CID_NUMBER, "") + ) + self._attr_extra_state_attributes[CID.CID_TIME] = last_state.attributes.get( + CID.CID_TIME, 0 + ) + + self.api.registercallback(self._async_incoming_call) + @callback def _async_incoming_call(self, new_state: str) -> None: """Handle new states.""" diff --git a/homeassistant/components/modern_forms/entity.py b/homeassistant/components/modern_forms/entity.py index c8419295c1f..0fab00f8f22 100644 --- a/homeassistant/components/modern_forms/entity.py +++ b/homeassistant/components/modern_forms/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -31,6 +31,9 @@ class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator """Return device information about this Modern Forms device.""" return DeviceInfo( identifiers={(DOMAIN, self.coordinator.data.info.mac_address)}, + connections={ + (CONNECTION_NETWORK_MAC, self.coordinator.data.info.mac_address) + }, name=self.coordinator.data.info.device_name, manufacturer="Modern Forms", model=self.coordinator.data.info.fan_type, diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index c426b942af5..e252338d4d8 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -1,15 +1,93 @@ """Calculates mold growth indication from temperature and humidity.""" +from __future__ import annotations + +from collections.abc import Callable +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device import ( + async_entity_id_to_device_id, + async_remove_stale_devices_links_keep_entity_device, +) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +from .const import CONF_INDOOR_HUMIDITY, CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mold indicator from a config entry.""" + # This can be removed in HA Core 2026.2 + async_remove_stale_devices_links_keep_entity_device( + hass, entry.entry_id, entry.options[CONF_INDOOR_HUMIDITY] + ) + + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id}, + ) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the humidity + # sensor, but not the temperature sensors because the mold_indicator links + # to the humidity sensor's device. + async_handle_source_entity_changes( + hass, + add_helper_config_entry_to_device=False, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_INDOOR_HUMIDITY] + ), + source_entity_id_or_uuid=entry.options[CONF_INDOOR_HUMIDITY], + ) + ) + + for temp_sensor in (CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP): + + def get_temp_sensor_updater( + temp_sensor: str, + ) -> Callable[[Event[er.EventEntityRegistryUpdatedData]], None]: + """Return a function to update the config entry with the new temp sensor.""" + + @callback + def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, temp_sensor: data["entity_id"]}, + ) + + return async_sensor_updated + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[temp_sensor], get_temp_sensor_updater(temp_sensor) + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -24,3 +102,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Remove the mold indicator config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_INDOOR_HUMIDITY] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index 5e5512a60bf..d370752fff9 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -101,6 +101,9 @@ class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 451cc65fb55..62906ea65ae 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -35,7 +35,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -173,7 +173,7 @@ class MoldIndicator(SensorEntity): self._indoor_hum: float | None = None self._crit_temp: float | None = None if indoor_humidity_sensor: - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, indoor_humidity_sensor, ) diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index c7683ebedd6..6e5c4c6181f 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -10,13 +10,7 @@ from homeassistant.const import CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - CONF_NOT_FIRST_RUN, - DOMAIN, - FIRST_RUN, - MONOPRICE_OBJECT, - UNDO_UPDATE_LISTENER, -) +from .const import CONF_NOT_FIRST_RUN, DOMAIN, FIRST_RUN, MONOPRICE_OBJECT PLATFORMS = [Platform.MEDIA_PLAYER] @@ -41,11 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, CONF_NOT_FIRST_RUN: True} ) - undo_listener = entry.add_update_listener(_update_listener) + entry.async_on_unload(entry.add_update_listener(_update_listener)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { MONOPRICE_OBJECT: monoprice, - UNDO_UPDATE_LISTENER: undo_listener, FIRST_RUN: first_run, } @@ -60,8 +53,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not unload_ok: return False - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - def _cleanup(monoprice) -> None: """Destroy the Monoprice object. diff --git a/homeassistant/components/monoprice/const.py b/homeassistant/components/monoprice/const.py index 576e4aa0e69..9dc9cad3831 100644 --- a/homeassistant/components/monoprice/const.py +++ b/homeassistant/components/monoprice/const.py @@ -18,4 +18,3 @@ SERVICE_RESTORE = "restore" FIRST_RUN = "first_run" MONOPRICE_OBJECT = "monoprice_object" -UNDO_UPDATE_LISTENER = "update_update_listener" diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index 7038cecd7ea..dc9a11be3ac 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/monzo", "iot_class": "cloud_polling", - "requirements": ["monzopy==1.4.2"] + "requirements": ["monzopy==1.5.1"] } diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 2abcc273e23..9c4d1a97f00 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -120,8 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -145,8 +143,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> multicast.Stop_listen() return unload_ok - - -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 954f9e25c21..8323c0e1995 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback @@ -38,7 +38,7 @@ CONFIG_SCHEMA = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 9cff2956a5f..04adc9f2d60 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -289,17 +289,23 @@ class MotionTiltDevice(MotionPositionDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, 180) + await self.async_request_position_till_stop() + 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, 0) + await self.async_request_position_till_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" angle = kwargs[ATTR_TILT_POSITION] * 180 / 100 async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, angle) + await self.async_request_position_till_stop() + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: @@ -360,11 +366,15 @@ class MotionTiltOnlyDevice(MotionTiltDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Open) + await self.async_request_position_till_stop() + 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.Close) + await self.async_request_position_till_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" angle = kwargs[ATTR_TILT_POSITION] @@ -376,6 +386,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_position, angle) + await self.async_request_position_till_stop() + async def async_set_absolute_position(self, **kwargs): """Move the cover to a specific absolute position (see TDBU).""" angle = kwargs.get(ATTR_TILT_POSITION) @@ -390,6 +402,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_position, angle) + await self.async_request_position_till_stop() + class MotionTDBUDevice(MotionBaseDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 483a638a0eb..9b52cbb01f5 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -42,6 +42,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind self._requesting_position: CALLBACK_TYPE | None = None self._previous_positions: list[int | dict | None] = [] + self._previous_angles: list[int | None] = [] if blind.device_type in DEVICE_TYPES_WIFI: self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI @@ -112,17 +113,27 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind """Request a state update from the blind at a scheduled point in time.""" # add the last position to the list and keep the list at max 2 items self._previous_positions.append(self._blind.position) + self._previous_angles.append(self._blind.angle) if len(self._previous_positions) > 2: del self._previous_positions[: len(self._previous_positions) - 2] + if len(self._previous_angles) > 2: + del self._previous_angles[: len(self._previous_angles) - 2] async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Update_trigger) self.coordinator.async_update_listeners() - if len(self._previous_positions) < 2 or not all( - self._blind.position == prev_position - for prev_position in self._previous_positions + if ( + len(self._previous_positions) < 2 + or not all( + self._blind.position == prev_position + for prev_position in self._previous_positions + ) + or len(self._previous_angles) < 2 + or not all( + self._blind.angle == prev_angle for prev_angle in self._previous_angles + ) ): # keep updating the position @self._update_interval_moving until the position does not change. self._requesting_position = async_call_later( @@ -132,6 +143,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind ) else: self._previous_positions = [] + self._previous_angles = [] self._requesting_position = None async def async_request_position_till_stop(self, delay: int | None = None) -> None: @@ -140,7 +152,8 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind delay = self._update_interval_moving self._previous_positions = [] - if self._blind.position is None: + self._previous_angles = [] + if self._blind.position is None and self._blind.angle is None: return if self._requesting_position is not None: self._requesting_position() diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index a82da20396f..ac5390f5c64 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.28"] + "requirements": ["motionblinds==0.6.30"] } diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 3e4ad53d200..fec176847da 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -277,11 +277,6 @@ def _add_camera( ) -async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up motionEye from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -382,7 +377,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_add_listener(_async_process_motioneye_cameras) ) await coordinator.async_refresh() - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 80a6449a22d..7704fb68412 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import callback @@ -186,7 +186,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): return MotionEyeOptionsFlow() -class MotionEyeOptionsFlow(OptionsFlow): +class MotionEyeOptionsFlow(OptionsFlowWithReload): """motionEye options flow.""" async def async_step_init( diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 931a57a71cc..52db0bd25da 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -60,6 +60,17 @@ from .const import ( CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, CONF_CURRENT_TEMP_TOPIC, + CONF_FAN_MODE_COMMAND_TEMPLATE, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_FAN_MODE_LIST, + CONF_FAN_MODE_STATE_TEMPLATE, + CONF_FAN_MODE_STATE_TOPIC, + CONF_HUMIDITY_COMMAND_TEMPLATE, + CONF_HUMIDITY_COMMAND_TOPIC, + CONF_HUMIDITY_MAX, + CONF_HUMIDITY_MIN, + CONF_HUMIDITY_STATE_TEMPLATE, + CONF_HUMIDITY_STATE_TOPIC, CONF_MODE_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TOPIC, CONF_MODE_LIST, @@ -68,14 +79,39 @@ from .const import ( CONF_POWER_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, + CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_PRESET_MODE_COMMAND_TOPIC, + CONF_PRESET_MODE_STATE_TOPIC, + CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_PRESET_MODES_LIST, CONF_RETAIN, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_LIST, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + CONF_SWING_MODE_COMMAND_TEMPLATE, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_LIST, + CONF_SWING_MODE_STATE_TEMPLATE, + CONF_SWING_MODE_STATE_TOPIC, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_HIGH_COMMAND_TEMPLATE, + CONF_TEMP_HIGH_COMMAND_TOPIC, + CONF_TEMP_HIGH_STATE_TEMPLATE, + CONF_TEMP_HIGH_STATE_TOPIC, CONF_TEMP_INITIAL, + CONF_TEMP_LOW_COMMAND_TEMPLATE, + CONF_TEMP_LOW_COMMAND_TOPIC, + CONF_TEMP_LOW_STATE_TEMPLATE, + CONF_TEMP_LOW_STATE_TOPIC, CONF_TEMP_MAX, CONF_TEMP_MIN, CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, + CONF_TEMP_STEP, + DEFAULT_CLIMATE_INITIAL_TEMPERATURE, DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) @@ -95,49 +131,6 @@ PARALLEL_UPDATES = 0 DEFAULT_NAME = "MQTT HVAC" -CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" -CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" -CONF_FAN_MODE_LIST = "fan_modes" -CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template" -CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic" - -CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" -CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" -CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" -CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" -CONF_HUMIDITY_MAX = "max_humidity" -CONF_HUMIDITY_MIN = "min_humidity" - -CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" -CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" -CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" -CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" -CONF_PRESET_MODES_LIST = "preset_modes" - -CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" -CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" -CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" -CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" -CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" - -CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" -CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" -CONF_SWING_MODE_LIST = "swing_modes" -CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" -CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" - -CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" -CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" -CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" -CONF_TEMP_HIGH_STATE_TOPIC = "temperature_high_state_topic" -CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template" -CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic" -CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template" -CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic" -CONF_TEMP_STEP = "temp_step" - -DEFAULT_INITIAL_TEMPERATURE = 21.0 - MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( { climate.ATTR_CURRENT_HUMIDITY, @@ -299,8 +292,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + vol.Optional(CONF_PRECISION): vol.All( + vol.Coerce(float), + vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]), ), vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_ACTION_TEMPLATE): cv.template, @@ -577,7 +571,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): init_temp: float = config.get( CONF_TEMP_INITIAL, TemperatureConverter.convert( - DEFAULT_INITIAL_TEMPERATURE, + DEFAULT_CLIMATE_INITIAL_TEMPERATURE, UnitOfTemperature.CELSIUS, self.temperature_unit, ), diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 7dca3a312d6..e27c1c2514b 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass from enum import IntEnum +import json import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError @@ -24,9 +25,17 @@ from cryptography.hazmat.primitives.serialization import ( ) from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol +import yaml from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass +from homeassistant.components.climate import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, + PRESET_NONE, +) from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState @@ -65,6 +74,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DISCOVERY, CONF_EFFECT, + CONF_ENTITY_CATEGORY, CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, @@ -76,6 +86,8 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, CONF_STATE_TEMPLATE, + CONF_TEMPERATURE_UNIT, + CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -83,8 +95,10 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + EntityCategory, + UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio @@ -113,6 +127,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.unit_conversion import TemperatureConverter from .addon import get_addon_manager from .client import MqttClientSetup @@ -121,6 +136,8 @@ from .const import ( ATTR_QOS, ATTR_RETAIN, ATTR_TOPIC, + CONF_ACTION_TEMPLATE, + CONF_ACTION_TOPIC, CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, CONF_BIRTH_MESSAGE, @@ -147,6 +164,10 @@ from .const import ( CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_CURRENT_HUMIDITY_TEMPLATE, + CONF_CURRENT_HUMIDITY_TOPIC, + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, CONF_DIRECTION_COMMAND_TEMPLATE, CONF_DIRECTION_COMMAND_TOPIC, CONF_DIRECTION_STATE_TOPIC, @@ -160,6 +181,11 @@ from .const import ( CONF_EFFECT_VALUE_TEMPLATE, CONF_ENTITY_PICTURE, CONF_EXPIRE_AFTER, + CONF_FAN_MODE_COMMAND_TEMPLATE, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_FAN_MODE_LIST, + CONF_FAN_MODE_STATE_TEMPLATE, + CONF_FAN_MODE_STATE_TOPIC, CONF_FLASH, CONF_FLASH_TIME_LONG, CONF_FLASH_TIME_SHORT, @@ -170,10 +196,21 @@ from .const import ( CONF_HS_COMMAND_TOPIC, CONF_HS_STATE_TOPIC, CONF_HS_VALUE_TEMPLATE, + CONF_HUMIDITY_COMMAND_TEMPLATE, + CONF_HUMIDITY_COMMAND_TOPIC, + CONF_HUMIDITY_MAX, + CONF_HUMIDITY_MIN, + CONF_HUMIDITY_STATE_TEMPLATE, + CONF_HUMIDITY_STATE_TOPIC, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, CONF_MAX_KELVIN, CONF_MIN_KELVIN, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, CONF_OFF_DELAY, CONF_ON_COMMAND_TYPE, CONF_OPTIONS, @@ -198,6 +235,9 @@ from .const import ( CONF_PERCENTAGE_VALUE_TEMPLATE, CONF_POSITION_CLOSED, CONF_POSITION_OPEN, + CONF_POWER_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TOPIC, + CONF_PRECISION, CONF_PRESET_MODE_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, @@ -234,6 +274,32 @@ from .const import ( CONF_STATE_VALUE_TEMPLATE, CONF_SUGGESTED_DISPLAY_PRECISION, CONF_SUPPORTED_COLOR_MODES, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_LIST, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + CONF_SWING_MODE_COMMAND_TEMPLATE, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_LIST, + CONF_SWING_MODE_STATE_TEMPLATE, + CONF_SWING_MODE_STATE_TOPIC, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_HIGH_COMMAND_TEMPLATE, + CONF_TEMP_HIGH_COMMAND_TOPIC, + CONF_TEMP_HIGH_STATE_TEMPLATE, + CONF_TEMP_HIGH_STATE_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_LOW_COMMAND_TEMPLATE, + CONF_TEMP_LOW_COMMAND_TOPIC, + CONF_TEMP_LOW_STATE_TEMPLATE, + CONF_TEMP_LOW_STATE_TOPIC, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + CONF_TEMP_STEP, CONF_TILT_CLOSED_POSITION, CONF_TILT_COMMAND_TEMPLATE, CONF_TILT_COMMAND_TOPIC, @@ -258,6 +324,7 @@ from .const import ( CONFIG_ENTRY_MINOR_VERSION, CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, + DEFAULT_CLIMATE_INITIAL_TEMPERATURE, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, @@ -322,6 +389,10 @@ SET_CLIENT_CERT = "set_client_cert" BOOLEAN_SELECTOR = BooleanSelector() TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) +TEXT_SELECTOR_READ_ONLY = TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, read_only=True) +) +URL_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.URL)) PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) PORT_SELECTOR = vol.All( NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)), @@ -386,6 +457,7 @@ KEY_UPLOAD_SELECTOR = FileSelector( SUBENTRY_PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.FAN, Platform.LIGHT, @@ -401,6 +473,7 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( ) ) TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) +TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True)) SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( { @@ -414,6 +487,14 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( ): TEXT_SELECTOR, } ) +ENTITY_CATEGORY_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[category.value for category in EntityCategory], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_ENTITY_CATEGORY, + sort=True, + ) +) # Sensor specific selectors SENSOR_DEVICE_CLASS_SELECTOR = SensorDeviceClassSelector(DeviceClassSelectorConfig()) @@ -425,6 +506,15 @@ BINARY_SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( sort=True, ) ) +SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[EntityCategory.DIAGNOSTIC.value], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_ENTITY_CATEGORY, + sort=True, + ) +) + BUTTON_DEVICE_CLASS_SELECTOR = SelectSelector( SelectSelectorConfig( options=[device_class.value for device_class in ButtonDeviceClass], @@ -452,6 +542,59 @@ TIMEOUT_SELECTOR = NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) ) +# Climate specific selectors +CLIMATE_MODE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["auto", "off", "cool", "heat", "dry", "fan_only"], + multiple=True, + translation_key="climate_modes", + ) +) + + +@callback +def temperature_selector(config: dict[str, Any]) -> Selector: + """Return a temperature selector with configured or system unit.""" + + return NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]), + ) + ) + + +@callback +def temperature_step_selector(config: dict[str, Any]) -> Selector: + """Return a temperature step selector.""" + + return NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=0.1, + max=10.0, + step=0.1, + unit_of_measurement=cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]), + ) + ) + + +TEMPERATURE_UNIT_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value="C", label="°C"), + SelectOptionDict(value="F", label="°F"), + ], + mode=SelectSelectorMode.DROPDOWN, + ) +) +PRECISION_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["1.0", "0.5", "0.1"], + mode=SelectSelectorMode.DROPDOWN, + ) +) + # Cover specific selectors POSITION_SELECTOR = NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX)) @@ -523,11 +666,94 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( ) ) +EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} + + +# Target temperature feature selector +@callback +def configured_target_temperature_feature(config: dict[str, Any]) -> str: + """Calculate current target temperature feature from config.""" + if ( + config == {CONF_PLATFORM: Platform.CLIMATE.value} + or CONF_TEMP_COMMAND_TOPIC in config + ): + # default to single on initial set + return "single" + if CONF_TEMP_HIGH_COMMAND_TOPIC in config: + return "high_low" + return "none" + + +TARGET_TEMPERATURE_FEATURE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["single", "high_low", "none"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="target_temperature_feature", + ) +) +HUMIDITY_SELECTOR = vol.All( + NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=100, step=1) + ), + vol.Coerce(int), +) + @callback -def validate_cover_platform_config( - config: dict[str, Any], -) -> dict[str, str]: +def temperature_default_from_celsius_to_system_default( + value: float, +) -> Callable[[dict[str, Any]], int]: + """Return temperature in Celsius in system default unit.""" + + def _default(config: dict[str, Any]) -> int: + return round( + TemperatureConverter.convert( + value, + UnitOfTemperature.CELSIUS, + cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]), + ) + ) + + return _default + + +@callback +def default_precision(config: dict[str, Any]) -> str: + """Return the thermostat precision for system default unit.""" + + return str( + config.get( + CONF_PRECISION, + 0.1 + if cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]) + is UnitOfTemperature.CELSIUS + else 1.0, + ) + ) + + +@callback +def validate_climate_platform_config(config: dict[str, Any]) -> dict[str, str]: + """Validate the climate platform options.""" + errors: dict[str, str] = {} + if ( + CONF_PRESET_MODES_LIST in config + and PRESET_NONE in config[CONF_PRESET_MODES_LIST] + ): + errors["climate_preset_mode_settings"] = "preset_mode_none_not_allowed" + if ( + CONF_HUMIDITY_MIN in config + and config[CONF_HUMIDITY_MIN] >= config[CONF_HUMIDITY_MAX] + ): + errors["target_humidity_settings"] = "max_below_min_humidity" + if CONF_TEMP_MIN in config and config[CONF_TEMP_MIN] >= config[CONF_TEMP_MAX]: + errors["target_temperature_settings"] = "max_below_min_temperature" + + return errors + + +@callback +def validate_cover_platform_config(config: dict[str, Any]) -> dict[str, str]: """Validate the cover platform options.""" errors: dict[str, str] = {} @@ -637,6 +863,14 @@ def validate_sensor_platform_config( return errors +@callback +def no_empty_list(value: list[Any]) -> list[Any]: + """Validate a selector returns at least one item.""" + if not value: + raise vol.Invalid("empty_list_not_allowed") + return value + + @callback def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: """Run validator, then return the unmodified input.""" @@ -652,13 +886,13 @@ def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: class PlatformField: """Stores a platform config field schema, required flag and validator.""" - selector: Selector[Any] | Callable[..., Selector[Any]] + selector: Selector[Any] | Callable[[dict[str, Any]], Selector[Any]] required: bool - validator: Callable[..., Any] | None = None + validator: Callable[[Any], Any] | None = None error: str | None = None - default: ( - str | int | bool | None | Callable[[dict[str, Any]], Any] | vol.Undefined - ) = vol.UNDEFINED + default: Any | None | Callable[[dict[str, Any]], Any] | vol.Undefined = ( + vol.UNDEFINED + ) is_schema_default: bool = False exclude_from_reconfig: bool = False exclude_from_config: bool = False @@ -721,12 +955,25 @@ COMMON_ENTITY_FIELDS: dict[str, PlatformField] = { ), } +SHARED_PLATFORM_ENTITY_FIELDS: dict[str, PlatformField] = { + CONF_ENTITY_CATEGORY: PlatformField( + selector=ENTITY_CATEGORY_SELECTOR, + required=False, + default=None, + ), +} + PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { Platform.BINARY_SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( selector=BINARY_SENSOR_DEVICE_CLASS_SELECTOR, required=False, ), + CONF_ENTITY_CATEGORY: PlatformField( + selector=SENSOR_ENTITY_CATEGORY_SELECTOR, + required=False, + default=None, + ), }, Platform.BUTTON.value: { CONF_DEVICE_CLASS: PlatformField( @@ -734,6 +981,78 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { required=False, ), }, + Platform.CLIMATE.value: { + CONF_TEMPERATURE_UNIT: PlatformField( + selector=TEMPERATURE_UNIT_SELECTOR, + validator=validate(cv.temperature_unit), + required=True, + exclude_from_reconfig=True, + default=lambda _: "C" + if async_get_hass().config.units.temperature_unit + is UnitOfTemperature.CELSIUS + else "F", + ), + "climate_feature_action": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_ACTION_TOPIC)), + ), + "climate_feature_target_temperature": PlatformField( + selector=TARGET_TEMPERATURE_FEATURE_SELECTOR, + required=False, + exclude_from_config=True, + default=configured_target_temperature_feature, + ), + "climate_feature_current_temperature": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_CURRENT_TEMP_TOPIC)), + ), + "climate_feature_target_humidity": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_HUMIDITY_COMMAND_TOPIC)), + ), + "climate_feature_current_humidity": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_HUMIDITY_STATE_TOPIC)), + ), + "climate_feature_preset_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_PRESET_MODES_LIST)), + ), + "climate_feature_fan_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_FAN_MODE_LIST)), + ), + "climate_feature_swing_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_SWING_MODE_LIST)), + ), + "climate_feature_swing_horizontal_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_SWING_HORIZONTAL_MODE_LIST)), + ), + "climate_feature_power": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_POWER_COMMAND_TOPIC)), + ), + }, Platform.COVER.value: { CONF_DEVICE_CLASS: PlatformField( selector=COVER_DEVICE_CLASS_SELECTOR, @@ -790,6 +1109,11 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { required=False, conditions=({"device_class": "enum"},), ), + CONF_ENTITY_CATEGORY: PlatformField( + selector=SENSOR_ENTITY_CATEGORY_SELECTOR, + required=False, + default=None, + ), }, Platform.SWITCH.value: { CONF_DEVICE_CLASS: PlatformField( @@ -868,6 +1192,496 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { ), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, + Platform.CLIMATE.value: { + # operation mode settings + CONF_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_MODE_LIST: PlatformField( + selector=CLIMATE_MODE_SELECTOR, + required=True, + default=[], + validator=validate(no_empty_list), + error="empty_list_not_allowed", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool) + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool) + ), + # current action settings + CONF_ACTION_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_action_settings", + conditions=({"climate_feature_action": True},), + ), + CONF_ACTION_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_action_settings", + conditions=({"climate_feature_action": True},), + ), + # target temperature settings + CONF_TEMP_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_LOW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_LOW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_LOW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_LOW_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_MIN: PlatformField( + selector=temperature_selector, + custom_filtering=True, + required=True, + default=temperature_default_from_celsius_to_system_default( + DEFAULT_MIN_TEMP + ), + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_TEMP_MAX: PlatformField( + selector=temperature_selector, + custom_filtering=True, + required=True, + default=temperature_default_from_celsius_to_system_default( + DEFAULT_MAX_TEMP + ), + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_PRECISION: PlatformField( + selector=PRECISION_SELECTOR, + required=False, + default=default_precision, + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_TEMP_STEP: PlatformField( + selector=temperature_step_selector, + custom_filtering=True, + required=False, + default=1.0, + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_TEMP_INITIAL: PlatformField( + selector=temperature_selector, + custom_filtering=True, + required=False, + default=temperature_default_from_celsius_to_system_default( + DEFAULT_CLIMATE_INITIAL_TEMPERATURE + ), + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + # current temperature settings + CONF_CURRENT_TEMP_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="current_temperature_settings", + conditions=({"climate_feature_current_temperature": True},), + ), + CONF_CURRENT_TEMP_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="current_temperature_settings", + conditions=({"climate_feature_current_temperature": True},), + ), + # target humidity settings + CONF_HUMIDITY_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_MIN: PlatformField( + selector=HUMIDITY_SELECTOR, + required=True, + default=DEFAULT_MIN_HUMIDITY, + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_MAX: PlatformField( + selector=HUMIDITY_SELECTOR, + required=True, + default=DEFAULT_MAX_HUMIDITY, + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + # current humidity settings + CONF_CURRENT_HUMIDITY_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="current_humidity_settings", + conditions=({"climate_feature_current_humidity": True},), + ), + CONF_CURRENT_HUMIDITY_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="current_humidity_settings", + conditions=({"climate_feature_current_humidity": True},), + ), + # power on/off support + CONF_POWER_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + CONF_POWER_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OFF, + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ON, + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + # preset mode settings + CONF_PRESET_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODES_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + # fan mode settings + CONF_FAN_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + # swing mode settings + CONF_SWING_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + # swing horizontal mode settings + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + }, Platform.COVER.value: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, @@ -1843,6 +2657,7 @@ ENTITY_CONFIG_VALIDATOR: dict[ ] = { Platform.BINARY_SENSOR.value: None, Platform.BUTTON.value: None, + Platform.CLIMATE.value: validate_climate_platform_config, Platform.COVER.value: validate_cover_platform_config, Platform.FAN.value: validate_fan_platform_config, Platform.LIGHT.value: validate_light_platform_config, @@ -1853,8 +2668,12 @@ ENTITY_CONFIG_VALIDATOR: dict[ MQTT_DEVICE_PLATFORM_FIELDS = { ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True), - ATTR_SW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), - ATTR_HW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_CONFIGURATION_URL: PlatformField( @@ -2032,15 +2851,15 @@ def data_schema_from_fields( no_reconfig_options: set[Any] = set() for schema_section in sections: data_schema_element = { - vol.Required(field_name, default=field_details.default) + vol.Required(field_name, default=get_default(field_details)) if field_details.required else vol.Optional( field_name, default=get_default(field_details) if field_details.default is not None else vol.UNDEFINED, - ): field_details.selector(component_data_with_user_input) # type: ignore[operator] - if field_details.custom_filtering + ): field_details.selector(component_data_with_user_input or {}) + if callable(field_details.selector) and field_details.custom_filtering else field_details.selector for field_name, field_details in data_schema_fields.items() if not field_details.is_schema_default @@ -2056,17 +2875,26 @@ def data_schema_from_fields( if field_details.section == schema_section and field_details.exclude_from_reconfig } - if not data_element_options: - continue if schema_section is None: data_schema.update(data_schema_element) continue + if not data_schema_element: + # Do not show empty sections + continue + # Collapse if values are changed or required fields need to be set collapsed = ( not any( (default := data_schema_fields[str(option)].default) is vol.UNDEFINED - or component_data_with_user_input[str(option)] != default + or ( + str(option) in component_data_with_user_input + and component_data_with_user_input[str(option)] != default + ) for option in data_element_options if option in component_data_with_user_input + or ( + str(option) in data_schema_fields + and data_schema_fields[str(option)].required + ) ) if component_data_with_user_input is not None else True @@ -2676,6 +3504,19 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): for field_key, value in data_schema.schema.items() } + @callback + def get_suggested_values_from_device_data( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from device data based on the data schema.""" + device_data = self._subentry_data["device"] + return { + field_key: self.get_suggested_values_from_device_data(value.schema) + if isinstance(value, section) + else device_data.get(field_key) + for field_key, value in data_schema.schema.items() + } + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -2705,15 +3546,24 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: + new_device_data: dict[str, Any] = user_input.copy() _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + if "advanced_settings" in new_device_data: + new_device_data |= new_device_data.pop("advanced_settings") if not errors: - self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data) if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() return await self.async_step_entity() - data_schema = self.add_suggested_values_to_schema( - data_schema, device_data if user_input is None else user_input - ) + data_schema = self.add_suggested_values_to_schema( + data_schema, device_data if user_input is None else user_input + ) + elif self.source == SOURCE_RECONFIGURE: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_device_data(data_schema), + ) + return self.async_show_form( step_id=CONF_DEVICE, data_schema=data_schema, @@ -2820,7 +3670,9 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): assert self._component_id is not None component_data = self._subentry_data["components"][self._component_id] platform = component_data[CONF_PLATFORM] - data_schema_fields = PLATFORM_ENTITY_FIELDS[platform] + data_schema_fields = ( + SHARED_PLATFORM_ENTITY_FIELDS | PLATFORM_ENTITY_FIELDS[platform] + ) errors: dict[str, str] = {} data_schema = data_schema_from_fields( @@ -2831,8 +3683,6 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): component_data=component_data, user_input=user_input, ) - if not data_schema.schema: - return await self.async_step_mqtt_platform_config() if user_input is not None: # Test entity fields against the validator merged_user_input, errors = validate_user_input( @@ -2926,6 +3776,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): platform = component_data[CONF_PLATFORM] platform_fields: dict[str, PlatformField] = ( COMMON_ENTITY_FIELDS + | SHARED_PLATFORM_ENTITY_FIELDS | PLATFORM_ENTITY_FIELDS[platform] | PLATFORM_MQTT_FIELDS[platform] ) @@ -3023,8 +3874,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): menu_options.append("delete_entity") menu_options.extend(["device", "availability"]) self._async_update_component_data_defaults() - if self._subentry_data != self._get_reconfigure_subentry().data: - menu_options.append("save_changes") + menu_options.append( + "save_changes" + if self._subentry_data != self._get_reconfigure_subentry().data + else "export" + ) return self.async_show_menu( step_id="summary_menu", menu_options=menu_options, @@ -3066,6 +3920,117 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): title=self._subentry_data[CONF_DEVICE][CONF_NAME], ) + async def async_step_export( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML or discovery payload.""" + return self.async_show_menu( + step_id="export", + menu_options=["export_yaml", "export_discovery"], + ) + + async def async_step_export_yaml( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML.""" + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + mqtt_yaml_config_base: dict[str, list[dict[str, dict[str, Any]]]] = {DOMAIN: []} + mqtt_yaml_config = mqtt_yaml_config_base[DOMAIN] + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config[CONF_DEVICE] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + platform = component_config.pop(CONF_PLATFORM) + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + mqtt_yaml_config.append({platform: component_config}) + + yaml_config = yaml.dump(mqtt_yaml_config_base) + data_schema = vol.Schema( + { + vol.Optional("yaml"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={"yaml": yaml_config}, + ) + return self.async_show_form( + step_id="export_yaml", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + + async def async_step_export_discovery( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config dor MQTT discovery.""" + + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + discovery_topic = f"homeassistant/device/{subentry.subentry_id}/config" + discovery_payload: dict[str, Any] = {} + discovery_payload.update(self._subentry_data.get("availability", {})) + discovery_payload["dev"] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + discovery_payload["o"] = {"name": "MQTT subentry export"} + discovery_payload["cmps"] = {} + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + discovery_payload["cmps"][component_id] = component_config + + data_schema = vol.Schema( + { + vol.Optional("discovery_topic"): TEXT_SELECTOR_READ_ONLY, + vol.Optional("discovery_payload"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={ + "discovery_topic": discovery_topic, + "discovery_payload": json.dumps(discovery_payload, indent=2), + }, + ) + return self.async_show_form( + step_id="export_discovery", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + @callback def async_is_pem_data(data: bytes) -> bool: diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c60aa674b1b..1dfdb8dac53 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -26,7 +26,6 @@ CONF_PAYLOAD_AVAILABLE = "payload_available" CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" CONF_AVAILABILITY = "availability" - CONF_AVAILABILITY_MODE = "availability_mode" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_AVAILABILITY_TOPIC = "availability_topic" @@ -53,7 +52,6 @@ CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" CONF_SUPPORTED_FEATURES = "supported_features" - CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" CONF_BLUE_TEMPLATE = "blue_template" @@ -91,6 +89,11 @@ CONF_EFFECT_TEMPLATE = "effect_template" CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" CONF_EXPIRE_AFTER = "expire_after" +CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" +CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" +CONF_FAN_MODE_LIST = "fan_modes" +CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template" +CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic" CONF_FLASH = "flash" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" @@ -101,6 +104,12 @@ CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TOPIC = "hs_command_topic" CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_VALUE_TEMPLATE = "hs_value_template" +CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" +CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" +CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" +CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" +CONF_HUMIDITY_MAX = "max_humidity" +CONF_HUMIDITY_MIN = "min_humidity" CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_MAX_KELVIN = "max_kelvin" CONF_MAX_MIREDS = "max_mireds" @@ -166,13 +175,32 @@ CONF_STATE_OPENING = "state_opening" CONF_STATE_STOPPED = "state_stopped" CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" +CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" +CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" +CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" +CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" +CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" +CONF_SWING_MODE_LIST = "swing_modes" +CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" +CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" -CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" -CONF_TEMP_STATE_TOPIC = "temperature_state_topic" +CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" +CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" +CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" +CONF_TEMP_HIGH_STATE_TOPIC = "temperature_high_state_topic" CONF_TEMP_INITIAL = "initial" +CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template" +CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic" +CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template" +CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" +CONF_TEMP_STATE_TOPIC = "temperature_state_topic" +CONF_TEMP_STEP = "temp_step" CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template" CONF_TILT_COMMAND_TOPIC = "tilt_command_topic" CONF_TILT_STATUS_TOPIC = "tilt_status_topic" @@ -213,6 +241,7 @@ CONF_SUPPORT_URL = "support_url" DEFAULT_BRIGHTNESS = False DEFAULT_BRIGHTNESS_SCALE = 255 +DEFAULT_CLIMATE_INITIAL_TEMPERATURE = 21.0 DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" DEFAULT_DISCOVERY = True diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index b62d42a80d0..f0e7f915551 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -247,6 +247,58 @@ def async_setup_entity_entry_helper( """Set up entity creation dynamically through MQTT discovery.""" mqtt_data = hass.data[DATA_MQTT] + @callback + def _async_migrate_subentry( + config: dict[str, Any], raw_config: dict[str, Any], migration_type: str + ) -> bool: + """Start a repair flow to allow migration of MQTT device subentries. + + If a YAML config or discovery is detected using the ID + of an existing mqtt subentry, and exported configuration is detected, + and a repair flow is offered to migrate the subentry. + """ + if ( + CONF_DEVICE in config + and CONF_IDENTIFIERS in config[CONF_DEVICE] + and config[CONF_DEVICE][CONF_IDENTIFIERS] + and (subentry_id := config[CONF_DEVICE][CONF_IDENTIFIERS][0]) + in entry.subentries + ): + name: str = config[CONF_DEVICE].get(CONF_NAME, "-") + if migration_type == "subentry_migration_yaml": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to YAML config: %s", + subentry_id, + raw_config, + ) + elif migration_type == "subentry_migration_discovery": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to configuration via MQTT discovery: %s", + subentry_id, + raw_config, + ) + async_create_issue( + hass, + DOMAIN, + subentry_id, + issue_domain=DOMAIN, + is_fixable=True, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(domain), + data={ + "entry_id": entry.entry_id, + "subentry_id": subentry_id, + "name": name, + }, + translation_placeholders={"name": name}, + translation_key=migration_type, + ) + return True + + return False + @callback def _async_setup_entity_entry_from_discovery( discovery_payload: MQTTDiscoveryPayload, @@ -263,9 +315,22 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None - async_add_entities( - [entity_class(hass, config, entry, discovery_payload.discovery_data)] - ) + if _async_migrate_subentry( + config, discovery_payload, "subentry_migration_discovery" + ): + _handle_discovery_failure(hass, discovery_payload) + _LOGGER.debug( + "MQTT discovery skipped, as device exists in subentry, " + "and repair flow must be completed first" + ) + else: + async_add_entities( + [ + entity_class( + hass, config, entry, discovery_payload.discovery_data + ) + ] + ) except vol.Invalid as err: _handle_discovery_failure(hass, discovery_payload) async_handle_schema_error(discovery_payload, err) @@ -313,6 +378,11 @@ def async_setup_entity_entry_helper( component_config.pop("platform") component_config.update(availability_config) component_config.update(device_mqtt_options) + if ( + CONF_ENTITY_CATEGORY in component_config + and component_config[CONF_ENTITY_CATEGORY] is None + ): + component_config.pop(CONF_ENTITY_CATEGORY) try: config = platform_schema_modern(component_config) @@ -341,6 +411,11 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None + if _async_migrate_subentry( + config, yaml_config, "subentry_migration_yaml" + ): + continue + entities.append(entity_class(hass, config, entry, None)) except vol.Invalid as exc: error = str(exc) @@ -384,16 +459,6 @@ def async_setup_entity_entry_helper( _async_setup_entities() -def init_entity_id_from_config( - hass: HomeAssistant, entity: Entity, config: ConfigType, entity_id_format: str -) -> None: - """Set entity_id from object_id if defined in config.""" - if CONF_OBJECT_ID in config: - entity.entity_id = async_generate_entity_id( - entity_id_format, config[CONF_OBJECT_ID], None, hass - ) - - class MqttAttributesMixin(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -1307,6 +1372,7 @@ class MqttEntity( _attr_should_poll = False _default_name: str | None _entity_id_format: str + _update_registry_entity_id: str | None = None def __init__( self, @@ -1341,13 +1407,33 @@ class MqttEntity( def _init_entity_id(self) -> None: """Set entity_id from object_id if defined in config.""" - init_entity_id_from_config( - self.hass, self, self._config, self._entity_id_format + if CONF_OBJECT_ID not in self._config: + return + self.entity_id = async_generate_entity_id( + self._entity_id_format, self._config[CONF_OBJECT_ID], None, self.hass ) + if self.unique_id is None: + return + # Check for previous deleted entities + entity_registry = er.async_get(self.hass) + entity_platform = self._entity_id_format.split(".")[0] + if ( + deleted_entry := entity_registry.deleted_entities.get( + (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 + self._update_registry_entity_id = self.entity_id @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" + if self._update_registry_entity_id is not None: + entity_registry = er.async_get(self.hass) + entity_registry.async_update_entity( + self.entity_id, new_entity_id=self._update_registry_entity_id + ) + await super().async_added_to_hass() self._subscriptions = {} self._prepare_subscribe_topics() diff --git a/homeassistant/components/mqtt/icons.json b/homeassistant/components/mqtt/icons.json index 73cbf22b629..1aa0902b77e 100644 --- a/homeassistant/components/mqtt/icons.json +++ b/homeassistant/components/mqtt/icons.json @@ -9,5 +9,10 @@ "reload": { "service": "mdi:reload" } + }, + "triggers": { + "_": { + "trigger": "mdi:swap-horizontal" + } } } diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 8a42797b0f2..4cc0424195a 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -364,6 +364,15 @@ class EntityTopicState: entity_id, entity = self.subscribe_calls.popitem() try: entity.async_write_ha_state() + except ValueError as exc: + _LOGGER.error( + "Value error while updating state of %s, topic: " + "'%s' with payload: %s: %s", + entity_id, + msg.topic, + msg.payload, + exc, + ) except Exception: _LOGGER.exception( "Exception raised while updating state of %s, topic: " diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index c3cc31bf04f..9da68e62d80 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -16,6 +16,7 @@ from homeassistant.components.number import ( NumberMode, RestoreNumber, ) +from homeassistant.components.sensor import AMBIGUOUS_UNITS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -70,6 +71,12 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset( def validate_config(config: ConfigType) -> ConfigType: """Validate that the configuration is valid, throws if it isn't.""" + if ( + CONF_UNIT_OF_MEASUREMENT in config + and (unit_of_measurement := config[CONF_UNIT_OF_MEASUREMENT]) in AMBIGUOUS_UNITS + ): + config[CONF_UNIT_OF_MEASUREMENT] = AMBIGUOUS_UNITS[unit_of_measurement] + if config[CONF_MIN] > config[CONF_MAX]: raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}") diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py new file mode 100644 index 00000000000..6a002904f11 --- /dev/null +++ b/homeassistant/components/mqtt/repairs.py @@ -0,0 +1,74 @@ +"""Repairs for MQTT.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + + +class MQTTDeviceEntryMigration(RepairsFlow): + """Handler to remove subentry for migrated MQTT device.""" + + def __init__(self, entry_id: str, subentry_id: str, name: str) -> None: + """Initialize the flow.""" + self.entry_id = entry_id + self.subentry_id = subentry_id + self.name = name + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + device_registry = dr.async_get(self.hass) + subentry_device = device_registry.async_get_device( + identifiers={(DOMAIN, self.subentry_id)} + ) + entry = self.hass.config_entries.async_get_entry(self.entry_id) + if TYPE_CHECKING: + assert entry is not None + assert subentry_device is not None + self.hass.config_entries.async_remove_subentry(entry, self.subentry_id) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={"name": self.name}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert data is not None + entry_id = data["entry_id"] + subentry_id = data["subentry_id"] + name = data["name"] + if TYPE_CHECKING: + assert isinstance(entry_id, str) + assert isinstance(subentry_id, str) + assert isinstance(name, str) + return MQTTDeviceEntryMigration( + entry_id=entry_id, + subentry_id=subentry_id, + name=name, + ) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 783a0b30b14..3423fc161ce 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( + AMBIGUOUS_UNITS, CONF_STATE_CLASS, DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, @@ -98,6 +99,12 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"together with state class `{state_class}`" ) + unit_of_measurement: str | None + if ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is not None and not unit_of_measurement.strip(): + config.pop(CONF_UNIT_OF_MEASUREMENT) + # Only allow `options` to be set for `enum` sensors # to limit the possible sensor values if (options := config.get(CONF_OPTIONS)) is not None: @@ -127,9 +134,14 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"together with state class '{state_class}'" ) - if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( - unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) - ) is None: + if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None: + return config + + unit_of_measurement = config[CONF_UNIT_OF_MEASUREMENT] = AMBIGUOUS_UNITS.get( + unit_of_measurement, unit_of_measurement + ) + + if (device_class := config.get(CONF_DEVICE_CLASS)) is None: return config if ( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 59c30d195ec..5312ac99697 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,8 +1,34 @@ { "issues": { + "deprecated_vacuum_battery_feature": { + "title": "Deprecated battery feature used", + "description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant." + }, "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "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](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." + }, + "subentry_migration_discovery": { + "title": "MQTT device \"{name}\" subentry migration to MQTT discovery", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_discovery::title%]", + "description": "Exported MQTT device \"{name}\" identified via MQTT discovery. Select **Submit** to confirm that the MQTT device is to be migrated to the main MQTT configuration, and to remove the existing MQTT device subentry. Make sure that the discovery is retained at the MQTT broker, or is resent after the subentry is removed, so that the MQTT device will be set up correctly. As an alternative you can change the device identifiers and entity unique ID-s in your MQTT discovery configuration payload, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } + }, + "subentry_migration_yaml": { + "title": "MQTT device \"{name}\" subentry migration to YAML", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_yaml::title%]", + "description": "Exported MQTT device \"{name}\" identified in YAML configuration. Select **Submit** to confirm that the MQTT device is to be migrated to main MQTT config entry, and to remove the existing MQTT device subentry. As an alternative you can change the device identifiers and entity unique ID-s in your configuration.yaml file, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } } }, "config": { @@ -107,14 +133,14 @@ "config_subentries": { "device": { "initiate_flow": { - "user": "Add MQTT Device", - "reconfigure": "Reconfigure MQTT Device" + "user": "Add MQTT device", + "reconfigure": "Reconfigure MQTT device" }, - "entry_type": "MQTT Device", + "entry_type": "MQTT device", "step": { "availability": { "title": "Availability options", - "description": "The availability feature allows a device to report it's availability.", + "description": "The availability feature allows a device to report its availability.", "data": { "availability_topic": "Availability topic", "availability_template": "Availability template", @@ -134,20 +160,27 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "configuration_url": "Configuration URL", - "sw_version": "Software version", - "hw_version": "Hardware version", "model": "Model", "model_id": "Model ID" }, "data_description": { "name": "The name of the manually added MQTT device.", "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.", - "sw_version": "The software version of the device. E.g. '2025.1.0'.", - "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", "model": "E.g. 'Cleanmaster Pro'.", "model_id": "E.g. '123NK2PRO'." }, "sections": { + "advanced_settings": { + "name": "Advanced device settings", + "data": { + "sw_version": "Software version", + "hw_version": "Hardware version" + }, + "data_description": { + "sw_version": "The software version of the device. E.g. '2025.1.0'.", + "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'." + } + }, "mqtt_settings": { "name": "MQTT settings", "data": { @@ -168,6 +201,7 @@ "delete_entity": "Delete an entity", "availability": "Configure availability", "device": "Update device properties", + "export": "Export MQTT device configuration", "save_changes": "Save changes" } }, @@ -209,7 +243,18 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure specific details for {platform} entity \"{entity}\":", "data": { + "climate_feature_action": "Current action support", + "climate_feature_current_humidity": "Current humidity support", + "climate_feature_current_temperature": "Current temperature support", + "climate_feature_fan_modes": "Fan mode support", + "climate_feature_power": "Power on/off support", + "climate_feature_preset_modes": "[%key:component::mqtt::config_subentries::device::step::entity_platform_config::data::fan_feature_preset_modes%]", + "climate_feature_swing_horizontal_modes": "Horizontal swing mode support", + "climate_feature_swing_modes": "Swing mode support", + "climate_feature_target_temperature": "Target temperature support", + "climate_feature_target_humidity": "Target humidity support", "device_class": "Device class", + "entity_category": "Entity category", "fan_feature_speed": "Speed support", "fan_feature_preset_modes": "Preset modes support", "fan_feature_oscillation": "Oscillation support", @@ -218,10 +263,22 @@ "schema": "Schema", "state_class": "State class", "suggested_display_precision": "Suggested display precision", + "temperature_unit": "Temperature unit", "unit_of_measurement": "Unit of measurement" }, "data_description": { - "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", + "climate_feature_action": "The climate supports reporting the current action.", + "climate_feature_current_humidity": "The climate supports reporting the current humidity.", + "climate_feature_current_temperature": "The climate supports reporting the current temperature.", + "climate_feature_fan_modes": "The climate supports fan modes.", + "climate_feature_power": "The climate supports the power \"on\" and \"off\" commands.", + "climate_feature_preset_modes": "The climate supports preset modes.", + "climate_feature_swing_horizontal_modes": "The climate supports horizontal swing modes.", + "climate_feature_swing_modes": "The climate supports swing modes.", + "climate_feature_target_temperature": "The climate supports setting the target temperature.", + "climate_feature_target_humidity": "The climate supports setting the target humidity.", + "device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)", + "entity_category": "Allows marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)", "fan_feature_speed": "The fan supports multiple speeds.", "fan_feature_preset_modes": "The fan supports preset modes.", "fan_feature_oscillation": "The fan supports oscillation.", @@ -230,6 +287,7 @@ "schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)", "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)", + "temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.", "unit_of_measurement": "Defines the unit of measurement of the sensor, if any." }, "sections": { @@ -258,6 +316,11 @@ "force_update": "Force update", "green_template": "Green template", "last_reset_value_template": "Last reset value template", + "modes": "Supported operation modes", + "mode_command_topic": "Operation mode command topic", + "mode_command_template": "Operation mode command template", + "mode_state_topic": "Operation mode state topic", + "mode_state_template": "Operation mode value template", "on_command_type": "ON command type", "optimistic": "Optimistic", "payload_off": "Payload \"off\"", @@ -285,6 +348,11 @@ "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", + "modes": "A list of supported operation modes. [Learn more.]({url}#modes)", + "mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)", + "mode_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)", + "mode_state_topic": "The MQTT topic subscribed to receive operation mode state messages. [Learn more.]({url}#mode_state_topic)", + "mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)", "on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.", "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", "payload_off": "The payload that represents the \"off\" state.", @@ -324,6 +392,100 @@ "transition": "Enable the transition feature for this light" } }, + "climate_action_settings": { + "name": "Current action settings", + "data": { + "action_template": "Action template", + "action_topic": "Action topic" + }, + "data_description": { + "action_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the action topic with.", + "action_topic": "The MQTT topic to subscribe for changes of the current action. If this is set, the climate graph uses the value received as data source. A \"None\" payload resets the current action state. An empty payload is ignored. Valid action values are: \"off\", \"heating\", \"cooling\", \"drying\", \"idle\" and \"fan\". [Learn more.]({url}#action_topic)" + } + }, + "climate_fan_mode_settings": { + "name": "Fan mode settings", + "data": { + "fan_modes": "Fan modes", + "fan_mode_command_topic": "Fan mode command topic", + "fan_mode_command_template": "Fan mode command template", + "fan_mode_state_topic": "Fan mode state topic", + "fan_mode_state_template": "Fan mode state template" + }, + "data_description": { + "fan_modes": "List of fan modes this climate is capable of running at. Common fan modes that offer translations are `off`, `on`, `auto`, `low`, `medium`, `high`, `middle`, `focus` and `diffuse`.", + "fan_mode_command_topic": "The MQTT topic to publish commands to change the climate fan mode. [Learn more.]({url}#fan_mode_command_topic)", + "fan_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the fan mode command topic.", + "fan_mode_state_topic": "The MQTT topic subscribed to receive the climate fan mode. [Learn more.]({url}#fan_mode_state_topic)", + "fan_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate fan mode value." + } + }, + "climate_power_settings": { + "name": "Power settings", + "data": { + "payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_off%]", + "payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_on%]", + "power_command_template": "Power command template", + "power_command_topic": "Power command topic" + }, + "data_description": { + "payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]", + "payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_on%]", + "power_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the power command topic. The `value` parameter is the payload set for payload \"on\" or payload \"off\".", + "power_command_topic": "The MQTT topic to publish commands to change the climate power state. Sends the payload configured with payload \"on\" or payload \"off\". [Learn more.]({url}#power_command_topic)" + } + }, + "climate_preset_mode_settings": { + "name": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::name%]", + "data": { + "preset_mode_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_command_template%]", + "preset_mode_command_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_command_topic%]", + "preset_mode_value_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_value_template%]", + "preset_mode_state_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_state_topic%]", + "preset_modes": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_modes%]" + }, + "data_description": { + "preset_mode_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_command_template%]", + "preset_mode_command_topic": "The MQTT topic to publish commands to change the climate preset mode. [Learn more.]({url}#preset_mode_command_topic)", + "preset_mode_value_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_value_template%]", + "preset_mode_state_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_state_topic%]", + "preset_modes": "List of preset modes this climate is capable of running at. Common preset modes that offer translations are `none`, `away`, `eco`, `boost`, `comfort`, `home`, `sleep` and `activity`." + } + }, + "climate_swing_horizontal_mode_settings": { + "name": "Horizontal swing mode settings", + "data": { + "swing_horizontal_modes": "Horizontal swing modes", + "swing_horizontal_mode_command_topic": "Horizontal swing mode command topic", + "swing_horizontal_mode_command_template": "Horizontal swing mode command template", + "swing_horizontal_mode_state_topic": "Horizontal swing mode state topic", + "swing_horizontal_mode_state_template": "Horizontal swing mode state template" + }, + "data_description": { + "swing_horizontal_modes": "List of horizontal swing modes this climate is capable of running at. Common horizontal swing modes that offer translations are `off` and `on`.", + "swing_horizontal_mode_command_topic": "The MQTT topic to publish commands to change the climate horizontal swing mode. [Learn more.]({url}#swing_horizontal_mode_command_topic)", + "swing_horizontal_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the horizontal swing mode command topic.", + "swing_horizontal_mode_state_topic": "The MQTT topic subscribed to receive the climate horizontal swing mode. [Learn more.]({url}#swing_horizontal_mode_state_topic)", + "swing_horizontal_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate horizontal swing mode value." + } + }, + "climate_swing_mode_settings": { + "name": "Swing mode settings", + "data": { + "swing_modes": "Swing modes", + "swing_mode_command_topic": "Swing mode command topic", + "swing_mode_command_template": "Swing mode command template", + "swing_mode_state_topic": "Swing mode state topic", + "swing_mode_state_template": "Swing mode state template" + }, + "data_description": { + "swing_modes": "List of swing modes this climate is capable of running at. Common swing modes that offer translations are `off`, `on`, `vertical`, `horizontal` and `both`.", + "swing_mode_command_topic": "The MQTT topic to publish commands to change the climate swing mode. [Learn more.]({url}#swing_mode_command_topic)", + "swing_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the swing mode command topic.", + "swing_mode_state_topic": "The MQTT topic subscribed to receive the climate swing mode. [Learn more.]({url}#swing_mode_state_topic)", + "swing_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate swing mode value." + } + }, "cover_payload_settings": { "name": "Payload settings", "data": { @@ -390,7 +552,29 @@ "tilt_opened_value": "The value that will be sent to the \"tilt command topic\" when the cover tilt is opened.", "tilt_status_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the payload for the tilt status topic. Within the template the following variables are available: `entity_id`, `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#tilt_status_template)", "tilt_status_topic": "The MQTT topic subscribed to receive tilt status update values. [Learn more.]({url}#tilt_status_topic)", - "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimisic mode by default. [Learn more.]({url}#tilt_optimistic)" + "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimistic mode by default. [Learn more.]({url}#tilt_optimistic)" + } + }, + "current_humidity_settings": { + "name": "Current humidity settings", + "data": { + "current_humidity_template": "Current humidity template", + "current_humidity_topic": "Current humidity topic" + }, + "data_description": { + "current_humidity_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the current humidity value. [Learn more.]({url}#current_humidity_template)", + "current_humidity_topic": "The MQTT topic subscribed to receive current humidity update values. [Learn more.]({url}#current_humidity_topic)" + } + }, + "current_temperature_settings": { + "name": "Current temperature settings", + "data": { + "current_temperature_template": "Current temperature template", + "current_temperature_topic": "Current temperature topic" + }, + "data_description": { + "current_temperature_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the current temperature value. [Learn more.]({url}#current_temperature_template)", + "current_temperature_topic": "The MQTT topic subscribed to receive current temperature update values. [Learn more.]({url}#current_temperature_topic)" } }, "light_brightness_settings": { @@ -616,8 +800,98 @@ "xy_state_topic": "The MQTT topic subscribed to receive XY state updates. The expected payload is the X and Y color values separated by commas, for example, `0.675,0.322`. [Learn more.]({url}#xy_state_topic)", "xy_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the XY value." } + }, + "target_humidity_settings": { + "name": "Target humidity settings", + "data": { + "max_humidity": "Maximum humidity", + "min_humidity": "Minimum humidity", + "target_humidity_command_template": "Target humidity command template", + "target_humidity_command_topic": "Target humidity command topic", + "target_humidity_state_template": "Target humidity state template", + "target_humidity_state_topic": "Target humidity state topic" + }, + "data_description": { + "max_humidity": "The maximum target humidity that can be set.", + "min_humidity": "The minimum target humidity that can be set.", + "target_humidity_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the target humidity command topic.", + "target_humidity_command_topic": "The MQTT topic to publish commands to change the climate target humidity. [Learn more.]({url}#humidity_command_topic)", + "target_humidity_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the target humidity state topic with.", + "target_humidity_state_topic": "The MQTT topic to subscribe for changes of the target humidity. [Learn more.]({url}#humidity_state_topic)" + } + }, + "target_temperature_settings": { + "name": "Target temperature settings", + "data": { + "initial": "Initial temperature", + "max_temp": "Maximum temperature", + "min_temp": "Minimum temperature", + "precision": "Precision", + "temp_step": "Temperature step", + "temperature_command_template": "Temperature command template", + "temperature_command_topic": "Temperature command topic", + "temperature_high_command_template": "Upper temperature command template", + "temperature_high_command_topic": "Upper temperature command topic", + "temperature_low_command_template": "Lower temperature command template", + "temperature_low_command_topic": "Lower temperature command topic", + "temperature_state_template": "Temperature state template", + "temperature_state_topic": "Temperature state topic", + "temperature_high_state_template": "Upper temperature state template", + "temperature_high_state_topic": "Upper temperature state topic", + "temperature_low_state_template": "Lower temperature state template", + "temperature_low_state_topic": "Lower temperature state topic" + }, + "data_description": { + "initial": "The climate initializes with this target temperature.", + "max_temp": "The maximum target temperature that can be set.", + "min_temp": "The minimum target temperature that can be set.", + "precision": "The precision in degrees the thermostat is working at.", + "temp_step": "The target temperature step in degrees Celsius or Fahrenheit.", + "temperature_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the temperature command topic.", + "temperature_command_topic": "The MQTT topic to publish commands to change the climate target temperature. [Learn more.]({url}#temperature_command_topic)", + "temperature_high_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the upper temperature command topic.", + "temperature_high_command_topic": "The MQTT topic to publish commands to change the climate upper target temperature. [Learn more.]({url}#temperature_high_command_topic)", + "temperature_low_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the lower temperature command topic.", + "temperature_low_command_topic": "The MQTT topic to publish commands to change the climate lower target temperature. [Learn more.]({url}#temperature_low_command_topic)", + "temperature_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the temperature state topic with.", + "temperature_state_topic": "The MQTT topic to subscribe for changes of the target temperature. [Learn more.]({url}#temperature_state_topic)", + "temperature_high_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the upper temperature state topic with.", + "temperature_high_state_topic": "The MQTT topic to subscribe for changes of the upper target temperature. [Learn more.]({url}#temperature_high_state_topic)", + "temperature_low_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the lower temperature state topic with.", + "temperature_low_state_topic": "The MQTT topic to subscribe for changes of the lower target temperature. [Learn more.]({url}#temperature_low_state_topic)" + } } } + }, + "export": { + "title": "Export MQTT device config", + "description": "An export allows you to migrate the MQTT device configuration to YAML-based configuration or MQTT discovery. The configuration export can also be helpful for troubleshooting.", + "menu_options": { + "export_discovery": "Export MQTT discovery information", + "export_yaml": "Export to YAML configuration" + } + }, + "export_yaml": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "You can copy the configuration below and place it your configuration.yaml file. Home Assistant will detect if the setup of the MQTT device was tried via YAML instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "yaml": "Copy the YAML configuration below:" + }, + "data_description": { + "yaml": "Place YAML configuration in your [configuration.yaml]({url}#yaml-configuration-listed-per-item)." + } + }, + "export_discovery": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "To allow setup via MQTT [discovery]({url}#device-discovery-payload), the discovery payload needs to be published to the discovery topic. Copy the information from the fields below. Home Assistant will detect if the setup of the MQTT device was tried via MQTT discovery instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "discovery_topic": "Discovery topic", + "discovery_payload": "Discovery payload:" + }, + "data_description": { + "discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload, used to trigger MQTT discovery. An empty payload published to this topic will remove the device and discovered entities.", + "discovery_payload": "The JSON [discovery payload]({url}#device-discovery-payload) that contains information about the MQTT device." + } } }, "abort": { @@ -633,6 +907,7 @@ "cover_tilt_command_template_must_be_used_with_tilt_command_topic": "The tilt command template must be used with the tilt command topic", "cover_tilt_status_template_must_be_used_with_tilt_status_topic": "The tilt value template must be used with the tilt status topic", "cover_value_template_must_be_used_with_state_topic": "The value template must be used with the state topic option", + "empty_list_not_allowed": "Empty list is not allowed. Add at least one item", "fan_speed_range_max_must_be_greater_than_speed_range_min": "Speed range max must be greater than speed range min", "fan_preset_mode_reset_in_preset_modes_list": "Payload \"reset preset mode\" is not a valid as a preset mode", "invalid_input": "Invalid value", @@ -643,10 +918,13 @@ "invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", "invalid_url": "Invalid URL", "last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only", + "max_below_min_humidity": "Max humidity value should be greater than min humidity value", "max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value", + "max_below_min_temperature": "Max temperature value should be greater than min temperature value", "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset", "options_with_enum_device_class": "Configure options for the enumeration sensor", + "preset_mode_none_not_allowed": "Preset \"none\" is not a valid preset mode", "uom_required_for_device_class": "The selected device class requires a unit" } } @@ -764,6 +1042,17 @@ } }, "selector": { + "climate_modes": { + "options": { + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", + "heat": "[%key:component::climate::entity_component::_::state::heat%]", + "cool": "[%key:component::climate::entity_component::_::state::cool%]", + "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", + "dry": "[%key:component::climate::entity_component::_::state::dry%]", + "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" + } + }, "device_class_binary_sensor": { "options": { "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", @@ -823,6 +1112,12 @@ "switch": "[%key:component::switch::title%]" } }, + "entity_category": { + "options": { + "config": "Config", + "diagnostic": "Diagnostic" + } + }, "light_schema": { "options": { "basic": "Default schema", @@ -841,6 +1136,7 @@ "options": { "binary_sensor": "[%key:component::binary_sensor::title%]", "button": "[%key:component::button::title%]", + "climate": "[%key:component::climate::title%]", "cover": "[%key:component::cover::title%]", "fan": "[%key:component::fan::title%]", "light": "[%key:component::light::title%]", @@ -868,6 +1164,13 @@ "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]" } + }, + "target_temperature_feature": { + "options": { + "single": "Single target temperature", + "high_low": "Upper/lower target temperature", + "none": "No target temperature" + } } }, "services": { @@ -916,6 +1219,23 @@ "description": "Reloads MQTT entities from the YAML-configuration." } }, + "triggers": { + "_": { + "name": "MQTT", + "description": "When a specific message is received on a given MQTT topic.", + "description_configured": "When an MQTT message has been received", + "fields": { + "payload": { + "name": "Payload", + "description": "The payload to trigger on." + }, + "topic": { + "name": "Topic", + "description": "MQTT topic to listen to." + } + } + } + }, "exceptions": { "addon_start_failed": { "message": "Failed to correctly start {addon} add-on." diff --git a/homeassistant/components/mqtt/triggers.yaml b/homeassistant/components/mqtt/triggers.yaml new file mode 100644 index 00000000000..0de44f4b39f --- /dev/null +++ b/homeassistant/components/mqtt/triggers.yaml @@ -0,0 +1,14 @@ +# Describes the format for MQTT triggers + +_: + fields: + payload: + example: "on" + required: false + selector: + text: + topic: + example: "living_room/switch/ac" + required: true + selector: + text: diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index f1d2eb34fe1..28cc883fa9e 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType @@ -25,11 +25,11 @@ from homeassistant.util.json import json_loads_object from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC -from .entity import MqttEntity, async_setup_entity_entry_helper +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, DOMAIN +from .entity import IssueSeverity, MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA -from .util import valid_publish_topic +from .util import learn_more_url, valid_publish_topic PARALLEL_UPDATES = 0 @@ -84,6 +84,8 @@ SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = { VacuumEntityFeature.STOP: "stop", VacuumEntityFeature.RETURN_HOME: "return_home", VacuumEntityFeature.FAN_SPEED: "fan_speed", + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 VacuumEntityFeature.BATTERY: "battery", VacuumEntityFeature.STATUS: "status", VacuumEntityFeature.SEND_COMMAND: "send_command", @@ -96,7 +98,6 @@ DEFAULT_SERVICES = ( VacuumEntityFeature.START | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT ) ALL_SERVICES = ( @@ -251,10 +252,35 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): ) } + async def mqtt_async_added_to_hass(self) -> None: + """Check for use of deprecated battery features.""" + if self.supported_features & VacuumEntityFeature.BATTERY: + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_vacuum_battery_feature_{self.entity_id}", + issue_domain=vacuum.DOMAIN, + breaks_in_ha_version="2026.2", + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(vacuum.DOMAIN), + translation_placeholders={"entity_id": self.entity_id}, + translation_key="deprecated_vacuum_battery_feature", + ) + _LOGGER.warning( + "MQTT vacuum entity %s implements the battery feature " + "which is deprecated. This will stop working " + "in Home Assistant 2026.2. Implement a separate entity " + "for the battery status instead", + self.entity_id, + ) + def _update_state_attributes(self, payload: dict[str, Any]) -> None: """Update the entity state attributes.""" self._state_attrs.update(payload) self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0) + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0))) @callback diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index 031229d1544..a0e82ba3315 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -8,6 +8,7 @@ from music_assistant_models.enums import MediaType import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -24,7 +25,6 @@ from .const import ( ATTR_ALBUMS, ATTR_ARTISTS, ATTR_AUDIOBOOKS, - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, ATTR_LIBRARY_ONLY, diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py index 7969954e443..445ef2c3e98 100644 --- a/homeassistant/components/music_assistant/button.py +++ b/homeassistant/components/music_assistant/button.py @@ -41,12 +41,6 @@ class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): translation_key="favorite_now_playing", ) - @property - def available(self) -> bool: - """Return availability of entity.""" - # mark the button as unavailable if the player has no current media item - return super().available and self.player.current_media is not None - @catch_musicassistant_error async def async_press(self) -> None: """Handle the button press command.""" diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index d2ee1f75028..8c1701b4afd 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -26,7 +26,6 @@ ATTR_OFFSET = "offset" ATTR_ORDER_BY = "order_by" ATTR_ALBUM_TYPE = "album_type" ATTR_ALBUM_ARTISTS_ONLY = "album_artists_only" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_URI = "uri" ATTR_IMAGE = "image" ATTR_VERSION = "version" diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py index f5b6d92b0cf..21fc072a639 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -34,7 +34,7 @@ class MusicAssistantEntity(Entity): identifiers={(DOMAIN, player_id)}, manufacturer=self.player.device_info.manufacturer or provider.name, model=self.player.device_info.model or self.player.name, - name=self.player.display_name, + name=self.player.name, configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}", ) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 28e8587e90c..4b28a1029a4 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.2.0"], + "requirements": ["music-assistant-client==1.2.4"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index 11cbbd3f655..e4724be650a 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -6,11 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from music_assistant_models.enums import MediaType as MASSMediaType -from music_assistant_models.media_items import ( - BrowseFolder, - MediaItemType, - SearchResults, -) +from music_assistant_models.media_items import MediaItemType, SearchResults from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -549,8 +545,6 @@ def _process_search_results( # Add available items to results for item in items: - if TYPE_CHECKING: - assert not isinstance(item, BrowseFolder) if not item.available: continue diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8d4e69bf082..3a210856391 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -248,10 +248,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): player = self.player active_queue = self.active_queue # update generic attributes - if player.powered and active_queue is not None: - self._attr_state = MediaPlayerState(active_queue.state.value) - if player.powered and player.state is not None: - self._attr_state = MediaPlayerState(player.state.value) + if player.powered and player.playback_state is not None: + self._attr_state = MediaPlayerState(player.playback_state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) # active source and source list (translate to HA source names) @@ -270,12 +268,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_source = active_source_name group_members: list[str] = [] - if player.group_childs: - group_members = player.group_childs + if player.group_members: + group_members = player.group_members elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): - group_members = parent.group_childs + group_members = parent.group_members - # translate MA group_childs to HA group_members as entity id's + # translate MA group_members to HA group_members as entity id's entity_registry = er.async_get(self.hass) group_members_entity_ids: list[str] = [ entity_id diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index c41bfa70d4c..37f0a8e9a85 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -102,7 +102,7 @@ "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used." }, "auto_play": { - "name": "Auto play", + "name": "Autoplay", "description": "Start playing the queue on the target player. Omit to use the default behavior." } } diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index a4b802f001c..f9cabda90b7 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mysensors", "iot_class": "local_push", "loggers": ["mysensors"], - "requirements": ["pymysensors==0.25.0"] + "requirements": ["pymysensors==0.26.0"] } diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 09cd7b42da0..9094fc11e1c 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -9,13 +9,11 @@ from pymystrom.bulb import MyStromBulb from pymystrom.exceptions import MyStromConnectionError from pymystrom.switch import MyStromSwitch -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .models import MyStromData +from .models import MyStromConfigEntry, MyStromData PLATFORMS_PLUGS = [Platform.SENSOR, Platform.SWITCH] PLATFORMS_BULB = [Platform.LIGHT] @@ -41,7 +39,7 @@ def _get_mystrom_switch(host: str) -> MyStromSwitch: return MyStromSwitch(host) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MyStromConfigEntry) -> bool: """Set up myStrom from a config entry.""" host = entry.data[CONF_HOST] try: @@ -73,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Unsupported myStrom device type: %s", device_type) return False - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = MyStromData( + entry.runtime_data = MyStromData( device=device, info=info, ) @@ -82,15 +80,12 @@ 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: MyStromConfigEntry) -> bool: """Unload a config entry.""" - device_type = hass.data[DOMAIN][entry.entry_id].info["type"] + device_type = entry.runtime_data.info["type"] platforms = [] if device_type in [101, 106, 107, 120]: platforms.extend(PLATFORMS_PLUGS) elif device_type in [102, 105]: platforms.extend(PLATFORMS_BULB) - 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/mystrom/light.py b/homeassistant/components/mystrom/light.py index 3942f601a20..67964d7d5b4 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -15,12 +15,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .models import MyStromConfigEntry _LOGGER = logging.getLogger(__name__) @@ -32,12 +32,12 @@ EFFECT_SUNRISE = "sunrise" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MyStromConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" - info = hass.data[DOMAIN][entry.entry_id].info - device = hass.data[DOMAIN][entry.entry_id].device + info = entry.runtime_data.info + device = entry.runtime_data.device async_add_entities([MyStromLight(device, entry.title, info["mac"])]) diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index eaf9eb6acdc..c5a981dbf46 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", "loggers": ["pymystrom"], - "requirements": ["python-mystrom==2.2.0"] + "requirements": ["python-mystrom==2.4.0"] } diff --git a/homeassistant/components/mystrom/models.py b/homeassistant/components/mystrom/models.py index 694a2f43df6..a96837070fd 100644 --- a/homeassistant/components/mystrom/models.py +++ b/homeassistant/components/mystrom/models.py @@ -6,6 +6,10 @@ from typing import Any from pymystrom.bulb import MyStromBulb from pymystrom.switch import MyStromSwitch +from homeassistant.config_entries import ConfigEntry + +type MyStromConfigEntry = ConfigEntry[MyStromData] + @dataclass class MyStromData: diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index bd5c9b923a2..251765d1658 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -13,13 +13,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .models import MyStromConfigEntry @dataclass(frozen=True) @@ -56,11 +56,11 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MyStromConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" - device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device + device: MyStromSwitch = entry.runtime_data.device async_add_entities( MyStromSwitchSensor(device, entry.title, description) diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index f626656a4e3..860d2dff727 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -8,12 +8,12 @@ from typing import Any from pymystrom.exceptions import MyStromConnectionError from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .models import MyStromConfigEntry DEFAULT_NAME = "myStrom Switch" @@ -22,11 +22,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MyStromConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" - device = hass.data[DOMAIN][entry.entry_id].device + device = entry.runtime_data.device async_add_entities([MyStromSwitch(device, entry.title)]) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index d297443c059..03ad5118352 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -44,15 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: translation_key="device_communication_error", translation_placeholders={"device": entry.title}, ) from err - - try: - await nam.async_check_credentials() - except (ApiError, ClientError) as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="device_communication_error", - translation_placeholders={"device": entry.title}, - ) from err except AuthFailedError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index fa94971e2ef..b90426b66e5 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass import logging from typing import Any @@ -26,15 +25,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN - -@dataclass -class NamConfig: - """NAM device configuration class.""" - - mac_address: str - auth_enabled: bool - - _LOGGER = logging.getLogger(__name__) AUTH_SCHEMA = vol.Schema( @@ -42,29 +32,14 @@ AUTH_SCHEMA = vol.Schema( ) -async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: - """Get device MAC address and auth_enabled property.""" - websession = async_get_clientsession(hass) - - options = ConnectionOptions(host) - nam = await NettigoAirMonitor.create(websession, options) - - mac = await nam.async_get_mac_address() - - return NamConfig(mac, nam.auth_enabled) - - -async def async_check_credentials( +async def async_get_nam( hass: HomeAssistant, host: str, data: dict[str, Any] -) -> None: - """Check if credentials are valid.""" +) -> NettigoAirMonitor: + """Get NAM client.""" websession = async_get_clientsession(hass) - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) - nam = await NettigoAirMonitor.create(websession, options) - - await nam.async_check_credentials() + return await NettigoAirMonitor.create(websession, options) class NAMFlowHandler(ConfigFlow, domain=DOMAIN): @@ -72,8 +47,8 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _config: NamConfig host: str + auth_enabled: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -85,21 +60,20 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self.host = user_input[CONF_HOST] try: - config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + return await self.async_step_credentials() except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) - if config.auth_enabled is True: - return await self.async_step_credentials() - return self.async_create_entry( title=self.host, data=user_input, @@ -119,7 +93,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + nam = await async_get_nam(self.hass, self.host, user_input) except AuthFailedError: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, TimeoutError): @@ -128,6 +102,9 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(format_mac(nam.mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + return self.async_create_entry( title=self.host, data={**user_input, CONF_HOST: self.host}, @@ -148,14 +125,16 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self.host}) try: - self._config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + self.auth_enabled = True + return await self.async_step_confirm_discovery() - await self.async_set_unique_id(format_mac(self._config.mac_address)) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + await self.async_set_unique_id(format_mac(nam.mac)) return await self.async_step_confirm_discovery() @@ -171,7 +150,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.host}, ) - if self._config.auth_enabled is True: + if self.auth_enabled is True: return await self.async_step_credentials() self._set_confirm_only() @@ -198,7 +177,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + await async_get_nam(self.hass, self.host, user_input) except ( ApiError, AuthFailedError, @@ -228,11 +207,11 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - config = await async_get_config(self.hass, user_input[CONF_HOST]) + nam = await async_get_nam(self.hass, user_input[CONF_HOST], {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_mismatch(reason="another_device") return self.async_update_reload_and_abort( diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 1c3b9db7a86..4799f657dda 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==4.1.0"], + "requirements": ["nettigo-air-monitor==5.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 253387c254a..d62168a4ad3 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -10,7 +10,12 @@ from typing import Any, Final, cast from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json @@ -200,7 +205,9 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="unknown") name = self.nanoleaf.name - await self.async_set_unique_id(name) + await self.async_set_unique_id( + name, raise_on_progress=self.source != SOURCE_USER + ) self._abort_if_unique_id_configured({CONF_HOST: self.nanoleaf.host}) if discovery_integration_import: diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py index 1992cc41c75..43998ef43b3 100644 --- a/homeassistant/components/nasweb/__init__.py +++ b/homeassistant/components/nasweb/__init__.py @@ -19,7 +19,7 @@ from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL from .coordinator import NASwebCoordinator from .nasweb_data import NASwebData -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] NASWEB_CONFIG_URL = "https://{host}/page" diff --git a/homeassistant/components/nasweb/const.py b/homeassistant/components/nasweb/const.py index ec750c90c8c..9150785d3bb 100644 --- a/homeassistant/components/nasweb/const.py +++ b/homeassistant/components/nasweb/const.py @@ -1,6 +1,7 @@ """Constants for the NASweb integration.""" DOMAIN = "nasweb" +KEY_TEMP_SENSOR = "temp_sensor" MANUFACTURER = "chomtech.pl" STATUS_UPDATE_MAX_TIME_INTERVAL = 60 SUPPORT_EMAIL = "support@chomtech.eu" diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py index 90dca0f3022..2865bffe9a5 100644 --- a/homeassistant/components/nasweb/coordinator.py +++ b/homeassistant/components/nasweb/coordinator.py @@ -11,16 +11,19 @@ from typing import Any from aiohttp.web import Request, Response from webio_api import WebioAPI -from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE +from webio_api.const import KEY_DEVICE_SERIAL, KEY_TYPE, TYPE_STATUS_UPDATE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol -from .const import STATUS_UPDATE_MAX_TIME_INTERVAL +from .const import KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL _LOGGER = logging.getLogger(__name__) +KEY_INPUTS = "inputs" +KEY_OUTPUTS = "outputs" + class NotificationCoordinator: """Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator.""" @@ -96,8 +99,11 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): self._job = HassJob(self._handle_max_update_interval, job_name) self._unsub_last_update_check: CALLBACK_TYPE | None = None self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} - data: dict[str, Any] = {} - data[KEY_OUTPUTS] = self.webio_api.outputs + data: dict[str, Any] = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(data) def is_connection_confirmed(self) -> bool: @@ -187,5 +193,9 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): async def process_status_update(self, new_status: dict) -> None: """Process status update from NASweb.""" self.webio_api.update_device_status(new_status) - new_data = {KEY_OUTPUTS: self.webio_api.outputs} + new_data = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(new_data) diff --git a/homeassistant/components/nasweb/icons.json b/homeassistant/components/nasweb/icons.json new file mode 100644 index 00000000000..0055bf2296a --- /dev/null +++ b/homeassistant/components/nasweb/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "sensor_input": { + "default": "mdi:help-circle-outline", + "state": { + "tamper": "mdi:lock-alert", + "active": "mdi:alert", + "normal": "mdi:shield-check-outline", + "problem": "mdi:alert-circle" + } + } + } + } +} diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py new file mode 100644 index 00000000000..eb342d7ce92 --- /dev/null +++ b/homeassistant/components/nasweb/sensor.py @@ -0,0 +1,189 @@ +"""Platform for NASweb sensors.""" + +from __future__ import annotations + +import logging +import time + +from webio_api import Input as NASwebInput, TempSensor + +from homeassistant.components.sensor import ( + DOMAIN as DOMAIN_SENSOR, + SensorDeviceClass, + SensorEntity, + SensorStateClass, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + BaseDataUpdateCoordinatorProtocol, +) + +from . import NASwebConfigEntry +from .const import DOMAIN, KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL + +SENSOR_INPUT_TRANSLATION_KEY = "sensor_input" +STATE_UNDEFINED = "undefined" +STATE_TAMPER = "tamper" +STATE_ACTIVE = "active" +STATE_NORMAL = "normal" +STATE_PROBLEM = "problem" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config: NASwebConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up Sensor platform.""" + coordinator = config.runtime_data + current_inputs: set[int] = set() + + @callback + def _check_entities() -> None: + received_inputs: dict[int, NASwebInput] = { + entry.index: entry for entry in coordinator.webio_api.inputs + } + added = {i for i in received_inputs if i not in current_inputs} + removed = {i for i in current_inputs if i not in received_inputs} + entities_to_add: list[InputStateSensor] = [] + for index in added: + webio_input = received_inputs[index] + if not isinstance(webio_input, NASwebInput): + _LOGGER.error("Cannot create InputStateSensor without NASwebInput") + continue + new_input = InputStateSensor(coordinator, webio_input) + entities_to_add.append(new_input) + current_inputs.add(index) + async_add_entities(entities_to_add) + entity_registry = er.async_get(hass) + 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 + ): + entity_registry.async_remove(entity_id) + current_inputs.remove(index) + else: + _LOGGER.warning("Failed to remove old input: no entity_id") + + coordinator.async_add_listener(_check_entities) + _check_entities() + + nasweb_temp_sensor = coordinator.data[KEY_TEMP_SENSOR] + temp_sensor = TemperatureSensor(coordinator, nasweb_temp_sensor) + async_add_entities([temp_sensor]) + + +class BaseSensorEntity(SensorEntity, BaseCoordinatorEntity): + """Base class providing common functionality.""" + + def __init__(self, coordinator: BaseDataUpdateCoordinatorProtocol) -> None: + """Initialize base sensor.""" + super().__init__(coordinator) + self._attr_available = False + self._attr_has_entity_name = True + self._attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + def _set_attr_available( + self, entity_last_update: float, available: bool | None + ) -> None: + if ( + self.coordinator.last_update is None + or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL + ): + self._attr_available = False + else: + self._attr_available = available if available is not None else False + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + """ + + +class InputStateSensor(BaseSensorEntity): + """Entity representing NASweb input.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options: list[str] = [ + STATE_UNDEFINED, + STATE_TAMPER, + STATE_ACTIVE, + STATE_NORMAL, + STATE_PROBLEM, + ] + _attr_translation_key = SENSOR_INPUT_TRANSLATION_KEY + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_input: NASwebInput, + ) -> None: + """Initialize InputStateSensor entity.""" + super().__init__(coordinator) + self._input = nasweb_input + self._attr_native_value: str | None = None + self._attr_translation_placeholders = {"index": f"{nasweb_input.index:2d}"} + self._attr_unique_id = ( + f"{DOMAIN}.{self._input.webio_serial}.input.{self._input.index}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._input.webio_serial)}, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._input.state is None or self._input.state in self._attr_options: + self._attr_native_value = self._input.state + else: + _LOGGER.warning("Received unrecognized input state: %s", self._input.state) + self._attr_native_value = None + self._set_attr_available(self._input.last_update, self._input.available) + self.async_write_ha_state() + + +class TemperatureSensor(BaseSensorEntity): + """Entity representing NASweb temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_temp_sensor: TempSensor, + ) -> None: + """Initialize TemperatureSensor entity.""" + super().__init__(coordinator) + self._temp_sensor = nasweb_temp_sensor + self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._temp_sensor.webio_serial)} + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self._temp_sensor.value + self._set_attr_available( + self._temp_sensor.last_update, self._temp_sensor.available + ) + self.async_write_ha_state() diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index 8b93ea10d79..73b91768374 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -15,7 +15,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_internal_url": "Make sure Home Assistant has a valid internal URL", - "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant.", + "missing_nasweb_data": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant.", "missing_status": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -25,13 +25,13 @@ }, "exceptions": { "config_entry_error_invalid_authentication": { - "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create a new one with the correct username/password." + "message": "Invalid username/password. Most likely the user has changed their password or has been removed. Delete this entry and create a new one with the correct username/password." }, "config_entry_error_internal_error": { - "message": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" + "message": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" }, "config_entry_error_no_status_update": { - "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + "message": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" }, "config_entry_error_missing_internal_url": { "message": "[%key:component::nasweb::config::error::missing_internal_url%]" @@ -43,7 +43,19 @@ "entity": { "switch": { "switch_output": { - "name": "Relay Switch {index}" + "name": "Relay switch {index}" + } + }, + "sensor": { + "sensor_input": { + "name": "Input {index}", + "state": { + "undefined": "Undefined", + "tamper": "Tamper", + "active": "[%key:common::state::active%]", + "normal": "[%key:common::state::normal%]", + "problem": "Problem" + } } } } diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 1fc3de9be6b..636a3a0d294 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -55,7 +55,7 @@ "description": "The Nest integration needs to re-authenticate your account" }, "oauth_discovery": { - "description": "Home Assistant has found a Google Nest device on your network. Be aware that the set up of Google Nest is more complicated than most other integrations and requires setting up a Nest Device Access project which **requires paying Google a US $5 fee**. Press **Submit** to continue setting up Google Nest." + "description": "Home Assistant has found a Google Nest device on your network. Be aware that the setup of Google Nest is more complicated than most other integrations and requires setting up a Nest Device Access project which **requires paying Google a US $5 fee**. Press **Submit** to continue setting up Google Nest." } }, "error": { diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f8f89ffd06b..a74ed630a4b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -38,6 +38,7 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + ATTR_SELECTED_SCHEDULE_ID, ATTR_TARGET_TEMPERATURE, ATTR_TIME_PERIOD, DATA_SCHEDULES, @@ -251,16 +252,22 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if data["event_type"] == EVENT_TYPE_SCHEDULE: # handle schedule change if "schedule_id" in data: + selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ + self.home.entity_id + ].get(data["schedule_id"]) self._selected_schedule = getattr( - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( - data["schedule_id"] - ), + selected_schedule, "name", None, ) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) + self.async_write_ha_state() self.data_handler.async_force_update(self._signal_name) # ignore other schedule events @@ -420,12 +427,14 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode] self._away = self._attr_hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - self._selected_schedule = getattr( - self.home.get_selected_schedule(), "name", None - ) + selected_schedule = self.home.get_selected_schedule() + self._selected_schedule = getattr(selected_schedule, "name", None) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) if self.device_type == NA_VALVE: self._attr_extra_state_attributes[ATTR_HEATING_POWER_REQUEST] = ( diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index d69a62f37f9..d8ecc72ada7 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -95,6 +95,7 @@ ATTR_PSEUDO = "pseudo" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" ATTR_SELECTED_SCHEDULE = "selected_schedule" +ATTR_SELECTED_SCHEDULE_ID = "selected_schedule_id" ATTR_TARGET_TEMPERATURE = "target_temperature" ATTR_TIME_PERIOD = "time_period" diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 595c57b1b4b..aeb4ffa0c55 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==9.2.1"] + "requirements": ["pyatmo==9.2.3"] } diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index fa18c3510ba..9aafa482faf 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -61,8 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_update_devices() -> bool: """Fetch data from the router.""" if router.track_devices: @@ -194,11 +192,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index a0a5b76eee5..3386d07cc6d 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -65,7 +65,7 @@ def _ordered_shared_schema(schema_input): } -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index eb8bd26cb9b..acc9504988d 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from datetime import timedelta from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -37,9 +36,6 @@ from .const import ( ATTR_STATUS, CONF_PROFILE_ID, DOMAIN, - UPDATE_INTERVAL_ANALYTICS, - UPDATE_INTERVAL_CONNECTION, - UPDATE_INTERVAL_SETTINGS, ) from .coordinator import ( NextDnsConnectionUpdateCoordinator, @@ -69,14 +65,14 @@ class NextDnsData: PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] -COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator], timedelta]] = [ - (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator, UPDATE_INTERVAL_CONNECTION), - (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_IP_VERSIONS, NextDnsIpVersionsUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_PROTOCOLS, NextDnsProtocolsUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_SETTINGS, NextDnsSettingsUpdateCoordinator, UPDATE_INTERVAL_SETTINGS), - (ATTR_STATUS, NextDnsStatusUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), +COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [ + (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator), + (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator), + (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator), + (ATTR_IP_VERSIONS, NextDnsIpVersionsUpdateCoordinator), + (ATTR_PROTOCOLS, NextDnsProtocolsUpdateCoordinator), + (ATTR_SETTINGS, NextDnsSettingsUpdateCoordinator), + (ATTR_STATUS, NextDnsStatusUpdateCoordinator), ] @@ -109,10 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b # Independent DataUpdateCoordinator is used for each API endpoint to avoid # unnecessary requests when entities using this endpoint are disabled. - for coordinator_name, coordinator_class, update_interval in COORDINATORS: - coordinator = coordinator_class( - hass, entry, nextdns, profile_id, update_interval - ) + for coordinator_name, coordinator_class in COORDINATORS: + coordinator = coordinator_class(hass, entry, nextdns, profile_id) tasks.append(coordinator.async_config_entry_first_refresh()) coordinators[coordinator_name] = coordinator diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 9b82e82ffe0..44470fe0070 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -29,7 +29,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import NextDnsConfigEntry -from .const import DOMAIN +from .const import ( + DOMAIN, + UPDATE_INTERVAL_ANALYTICS, + UPDATE_INTERVAL_CONNECTION, + UPDATE_INTERVAL_SETTINGS, +) _LOGGER = logging.getLogger(__name__) @@ -40,6 +45,7 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( """Class to manage fetching NextDNS data API.""" config_entry: NextDnsConfigEntry + _update_interval: timedelta def __init__( self, @@ -47,7 +53,6 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( config_entry: NextDnsConfigEntry, nextdns: NextDns, profile_id: str, - update_interval: timedelta, ) -> None: """Initialize.""" self.nextdns = nextdns @@ -58,7 +63,7 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=update_interval, + update_interval=self._update_interval, ) async def _async_update_data(self) -> CoordinatorDataT: @@ -93,6 +98,8 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): """Class to manage fetching NextDNS analytics status data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsStatus: """Update data via library.""" return await self.nextdns.get_analytics_status(self.profile_id) @@ -101,6 +108,8 @@ class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): """Class to manage fetching NextDNS analytics Dnssec data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsDnssec: """Update data via library.""" return await self.nextdns.get_analytics_dnssec(self.profile_id) @@ -109,6 +118,8 @@ class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): """Class to manage fetching NextDNS analytics encryption data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsEncryption: """Update data via library.""" return await self.nextdns.get_analytics_encryption(self.profile_id) @@ -117,6 +128,8 @@ class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncry class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): """Class to manage fetching NextDNS analytics IP versions data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsIpVersions: """Update data via library.""" return await self.nextdns.get_analytics_ip_versions(self.profile_id) @@ -125,6 +138,8 @@ class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVer class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): """Class to manage fetching NextDNS analytics protocols data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsProtocols: """Update data via library.""" return await self.nextdns.get_analytics_protocols(self.profile_id) @@ -133,6 +148,8 @@ class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtoc class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): """Class to manage fetching NextDNS connection data from API.""" + _update_interval = UPDATE_INTERVAL_SETTINGS + async def _async_update_data_internal(self) -> Settings: """Update data via library.""" return await self.nextdns.get_settings(self.profile_id) @@ -141,6 +158,8 @@ class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): """Class to manage fetching NextDNS connection data from API.""" + _update_interval = UPDATE_INTERVAL_CONNECTION + async def _async_update_data_internal(self) -> ConnectionStatus: """Update data via library.""" return await self.nextdns.connection_status(self.profile_id) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index d10a1728a94..4fdbcdb7175 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], - "requirements": ["nextdns==4.0.0"] + "requirements": ["nextdns==4.1.0"] } diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 8d7bd6a215f..a9bf635673a 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -328,6 +328,9 @@ "block_zoom": { "name": "Block Zoom" }, + "bypass_age_verification": { + "name": "Bypass age verification" + }, "cache_boost": { "name": "Cache boost" }, diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 872f7430b3d..48151eb185c 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -56,6 +56,12 @@ SWITCHES = ( entity_category=EntityCategory.CONFIG, state=lambda data: data.anonymized_ecs, ), + NextDnsSwitchEntityDescription( + key="bav", + translation_key="bypass_age_verification", + entity_category=EntityCategory.CONFIG, + state=lambda data: data.bav, + ), NextDnsSwitchEntityDescription( key="logs", translation_key="logs", diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 50674a7ed46..bdda0d30356 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,11 +1,8 @@ """The NFAndroidTV integration.""" -from notifications_android_tv.notifications import ConnectError, Notifications - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType @@ -25,14 +22,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NFAndroidTV from a config entry.""" - try: - await hass.async_add_executor_job(Notifications, entry.data[CONF_HOST]) - except ConnectError as ex: - raise ConfigEntryNotReady( - f"Failed to connect to host: {entry.data[CONF_HOST]}" - ) from ex - hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = entry.data[CONF_HOST] hass.async_create_task( discovery.async_load_platform( diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index f6d9bcde432..c1c19a600b9 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -6,7 +6,7 @@ from io import BufferedReader import logging from typing import Any -from notifications_android_tv import Notifications +from notifications_android_tv.notifications import ConnectError, Notifications import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -59,9 +59,9 @@ async def async_get_service( """Get the NFAndroidTV notification service.""" if discovery_info is None: return None - notify = await hass.async_add_executor_job(Notifications, discovery_info[CONF_HOST]) + return NFAndroidTVNotificationService( - notify, + discovery_info[CONF_HOST], hass.config.is_allowed_path, ) @@ -71,15 +71,24 @@ class NFAndroidTVNotificationService(BaseNotificationService): def __init__( self, - notify: Notifications, + host: str, is_allowed_path: Any, ) -> None: """Initialize the service.""" - self.notify = notify + self.host = host self.is_allowed_path = is_allowed_path + self.notify: Notifications | None = None def send_message(self, message: str, **kwargs: Any) -> None: - """Send a message to a Android TV device.""" + """Send a message to an Android TV device.""" + if self.notify is None: + try: + self.notify = Notifications(self.host) + except ConnectError as err: + raise HomeAssistantError( + f"Failed to connect to host: {self.host}" + ) from err + data: dict | None = kwargs.get(ATTR_DATA) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) duration = None @@ -178,18 +187,22 @@ class NFAndroidTVNotificationService(BaseNotificationService): translation_key="invalid_notification_icon", translation_placeholders={"type": type(icondata).__name__}, ) - self.notify.send( - message, - title=title, - duration=duration, - fontsize=fontsize, - position=position, - bkgcolor=bkgcolor, - transparency=transparency, - interrupt=interrupt, - icon=icon, - image_file=image_file, - ) + + try: + self.notify.send( + message, + title=title, + duration=duration, + fontsize=fontsize, + position=position, + bkgcolor=bkgcolor, + transparency=transparency, + interrupt=interrupt, + icon=icon, + image_file=image_file, + ) + except ConnectError as err: + raise HomeAssistantError(f"Failed to connect to host: {self.host}") from err def load_file( self, diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 284e4d83569..d49862180bd 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -39,6 +39,7 @@ class BinarySensor(CoilEntity, BinarySensorEntity): def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) + self._on_value = coil.get_mapping_for(1) def _async_read_coil(self, data: CoilData) -> None: - self._attr_is_on = data.value == "ON" + self._attr_is_on = data.value == self._on_value diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index 849912af656..8b6c8abf359 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -52,6 +52,7 @@ class NibeAlarmResetButton(CoordinatorEntity[CoilCoordinator], ButtonEntity): async def async_press(self) -> None: """Execute the command.""" + await self.coordinator.async_write_coil(self._reset_coil, 0) await self.coordinator.async_write_coil(self._reset_coil, 1) await self.coordinator.async_read_coil(self._alarm_coil) diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 2451e2fbda9..05e652d7f42 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -10,12 +10,19 @@ from typing import Any from nibe.coil import Coil, CoilData from nibe.connection import Connection -from nibe.exceptions import CoilNotFoundException, ReadException +from nibe.exceptions import ( + CoilNotFoundException, + ReadException, + WriteDeniedException, + WriteException, + WriteTimeoutException, +) from nibe.heatpump import HeatPump, Series from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -134,7 +141,33 @@ class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): async def async_write_coil(self, coil: Coil, value: float | str) -> None: """Write coil and update state.""" data = CoilData(coil, value) - await self.connection.write_coil(data) + try: + await self.connection.write_coil(data) + except WriteDeniedException: + LOGGER.debug( + "Denied write on address %d with value %s. This is likely already the value the pump has internally", + coil.address, + value, + ) + except WriteTimeoutException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_timeout", + translation_placeholders={ + "address": str(coil.address), + }, + ) from e + except WriteException as e: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_failed", + translation_placeholders={ + "address": str(coil.address), + "value": str(value), + "error": str(e), + }, + ) from e self.data[coil.address] = data diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index d85e5e9b765..59f365f52bf 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from nibe.coil import Coil, CoilData -from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity +from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -61,6 +61,7 @@ class Number(CoilEntity, NumberEntity): self._attr_native_step = 1 / coil.factor self._attr_native_unit_of_measurement = coil.unit + self._attr_mode = NumberMode.BOX def _async_read_coil(self, data: CoilData) -> None: if data.value is None: diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index c65a76d3364..1b339526586 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -45,5 +45,13 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "url": "The specified URL is not well formed nor supported" } + }, + "exceptions": { + "write_timeout": { + "message": "Timeout while writing coil {address}" + }, + "write_failed": { + "message": "Writing of coil {address} with value `{value}` failed with error `{error}`" + } } } diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 2daf3fc48ff..452244f05b5 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -41,14 +41,16 @@ class Switch(CoilEntity, SwitchEntity): def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) + self._on_value = coil.get_mapping_for(1) + self._off_value = coil.get_mapping_for(0) def _async_read_coil(self, data: CoilData) -> None: - self._attr_is_on = data.value == "ON" + self._attr_is_on = data.value == self._on_value async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self._async_write_coil("ON") + await self._async_write_coil(self._on_value) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._async_write_coil("OFF") + await self._async_write_coil(self._off_value) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index e074f7ad000..f9b23faa234 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -37,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -49,8 +47,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener(hass: HomeAssistant, entry: NinaConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index be37a802d47..cfbdd87a0e2 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -52,6 +52,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 0 + + class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): """Representation of an NINA warning.""" diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 24c016e5e64..f7bc0914481 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -165,8 +165,8 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" +class OptionsFlowHandler(OptionsFlowWithReload): + """Handle an option flow for NINA.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/nina/diagnostics.py b/homeassistant/components/nina/diagnostics.py new file mode 100644 index 00000000000..f62b7b6bcec --- /dev/null +++ b/homeassistant/components/nina/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics for the Nina integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import NinaConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NinaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + runtime_data_dict = { + region_key: [asdict(warning) for warning in region_data] + for region_key, region_data in entry.runtime_data.data.items() + } + + return { + "entry_data": dict(entry.data), + "data": runtime_data_dict, + } diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 72bf9284573..2aa77e09d16 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -88,16 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) await scanner.async_setup() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - 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/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 1f436edd60c..e3d1ecbdb14 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -138,7 +138,7 @@ async def _async_build_schema_with_user_input( return vol.Schema(schema) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for homekit.""" def __init__(self, config_entry: ConfigEntry) -> None: diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 3bbf46f0264..7c886c534cb 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -42,8 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - await hub.start() return True @@ -58,10 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def options_update_listener( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 7e1ae4c1d9b..05ece456f15 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import callback @@ -173,7 +173,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -187,7 +187,7 @@ class NoboHubConnectError(HomeAssistantError): self.msg = msg -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index c24dbe3d21d..566ff88abac 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -69,10 +69,12 @@ class NoboGlobalSelector(SelectEntity): self._override_type = override_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, hub.hub_serial)}, + serial_number=hub.hub_serial, name=hub.hub_info[ATTR_NAME], manufacturer=NOBO_MANUFACTURER, - model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", + model="Nobø Ecohub", sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], + hw_version=hub.hub_info[ATTR_HARDWARE_VERSION], ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 382fd1b0bf4..6a394f23f4c 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -58,6 +58,7 @@ class NoboTemperatureSensor(SensorEntity): suggested_area = hub.zones[zone_id][ATTR_NAME] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, component[ATTR_SERIAL])}, + serial_number=component[ATTR_SERIAL], name=component[ATTR_NAME], manufacturer=NOBO_MANUFACTURER, model=component[ATTR_MODEL].name, diff --git a/homeassistant/components/nordpool/const.py b/homeassistant/components/nordpool/const.py index 19a978d946c..1fd3009321b 100644 --- a/homeassistant/components/nordpool/const.py +++ b/homeassistant/components/nordpool/const.py @@ -12,3 +12,4 @@ PLATFORMS = [Platform.SENSOR] DEFAULT_NAME = "Nord Pool" CONF_AREAS = "areas" +ATTR_RESOLUTION = "resolution" diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index a6cfd40c323..d2edb81b9e6 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING +import aiohttp from pynordpool import ( Currency, DeliveryPeriodData, @@ -91,6 +92,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): except ( NordPoolResponseError, NordPoolError, + TimeoutError, + aiohttp.ClientError, ) as error: LOGGER.debug("Connection error: %s", error) self.async_set_update_error(error) diff --git a/homeassistant/components/nordpool/icons.json b/homeassistant/components/nordpool/icons.json index 5a1a3df3d92..42449b7a1a5 100644 --- a/homeassistant/components/nordpool/icons.json +++ b/homeassistant/components/nordpool/icons.json @@ -42,6 +42,9 @@ "services": { "get_prices_for_date": { "service": "mdi:cash-multiple" + }, + "get_price_indices_for_date": { + "service": "mdi:cash-multiple" } } } diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 9bb97d0737b..e568764871a 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -2,16 +2,21 @@ from __future__ import annotations +from collections.abc import Callable from datetime import date, datetime +from functools import partial import logging from typing import TYPE_CHECKING from pynordpool import ( AREAS, Currency, + DeliveryPeriodData, NordPoolAuthenticationError, + NordPoolClient, NordPoolEmptyResponseError, NordPoolError, + PriceIndicesData, ) import voluptuous as vol @@ -32,7 +37,7 @@ from homeassistant.util.json import JsonValueType if TYPE_CHECKING: from . import NordPoolConfigEntry -from .const import DOMAIN +from .const import ATTR_RESOLUTION, DOMAIN _LOGGER = logging.getLogger(__name__) ATTR_CONFIG_ENTRY = "config_entry" @@ -40,6 +45,7 @@ ATTR_AREAS = "areas" ATTR_CURRENCY = "currency" SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date" +SERVICE_GET_PRICE_INDICES_FOR_DATE = "get_price_indices_for_date" SERVICE_GET_PRICES_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -50,6 +56,13 @@ SERVICE_GET_PRICES_SCHEMA = vol.Schema( ), } ) +SERVICE_GET_PRICE_INDICES_SCHEMA = SERVICE_GET_PRICES_SCHEMA.extend( + { + vol.Optional(ATTR_RESOLUTION, default=60): vol.All( + cv.positive_int, vol.All(vol.Coerce(int), vol.In((15, 30, 60))) + ), + } +) def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry: @@ -71,11 +84,13 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry: def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Nord Pool integration.""" - async def get_prices_for_date(call: ServiceCall) -> ServiceResponse: - """Get price service.""" + def get_service_params( + call: ServiceCall, + ) -> tuple[NordPoolClient, date, str, list[str], int]: + """Return the parameters for the service.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - asked_date: date = call.data[ATTR_DATE] client = entry.runtime_data.client + asked_date: date = call.data[ATTR_DATE] areas: list[str] = entry.data[ATTR_AREAS] if _areas := call.data.get(ATTR_AREAS): @@ -85,14 +100,55 @@ def async_setup_services(hass: HomeAssistant) -> None: if _currency := call.data.get(ATTR_CURRENCY): currency = _currency + resolution: int = 60 + if _resolution := call.data.get(ATTR_RESOLUTION): + resolution = _resolution + areas = [area.upper() for area in areas] currency = currency.upper() + return (client, asked_date, currency, areas, resolution) + + async def get_prices_for_date( + client: NordPoolClient, + asked_date: date, + currency: str, + areas: list[str], + resolution: int, + ) -> DeliveryPeriodData: + """Get prices.""" + return await client.async_get_delivery_period( + datetime.combine(asked_date, dt_util.utcnow().time()), + Currency(currency), + areas, + ) + + async def get_price_indices_for_date( + client: NordPoolClient, + asked_date: date, + currency: str, + areas: list[str], + resolution: int, + ) -> PriceIndicesData: + """Get prices.""" + return await client.async_get_price_indices( + datetime.combine(asked_date, dt_util.utcnow().time()), + Currency(currency), + areas, + resolution=resolution, + ) + + async def get_prices(func: Callable, call: ServiceCall) -> ServiceResponse: + """Get price service.""" + client, asked_date, currency, areas, resolution = get_service_params(call) + try: - price_data = await client.async_get_delivery_period( - datetime.combine(asked_date, dt_util.utcnow().time()), - Currency(currency), + price_data = await func( + client, + asked_date, + currency, areas, + resolution, ) except NordPoolAuthenticationError as error: raise ServiceValidationError( @@ -122,7 +178,14 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_GET_PRICES_FOR_DATE, - get_prices_for_date, + partial(get_prices, get_prices_for_date), schema=SERVICE_GET_PRICES_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRICE_INDICES_FOR_DATE, + partial(get_prices, get_price_indices_for_date), + schema=SERVICE_GET_PRICE_INDICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/nordpool/services.yaml b/homeassistant/components/nordpool/services.yaml index dded8482c6f..f18d705f54b 100644 --- a/homeassistant/components/nordpool/services.yaml +++ b/homeassistant/components/nordpool/services.yaml @@ -46,3 +46,59 @@ get_prices_for_date: - "PLN" - "SEK" mode: dropdown +get_price_indices_for_date: + fields: + config_entry: + required: true + selector: + config_entry: + integration: nordpool + date: + required: true + selector: + date: + areas: + selector: + select: + options: + - "EE" + - "LT" + - "LV" + - "AT" + - "BE" + - "FR" + - "GER" + - "NL" + - "PL" + - "DK1" + - "DK2" + - "FI" + - "NO1" + - "NO2" + - "NO3" + - "NO4" + - "NO5" + - "SE1" + - "SE2" + - "SE3" + - "SE4" + - "SYS" + mode: dropdown + currency: + selector: + select: + options: + - "DKK" + - "EUR" + - "NOK" + - "PLN" + - "SEK" + mode: dropdown + resolution: + selector: + select: + options: + - "15" + - "30" + - "60" + mode: dropdown diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 73c35673826..3494996af01 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -103,7 +103,7 @@ }, "date": { "name": "Date", - "description": "Only dates two months in the past and one day in the future is allowed." + "description": "Only dates in the range from two months in the past to one day in the future are allowed." }, "areas": { "name": "Areas", @@ -114,6 +114,32 @@ "description": "Currency to get prices in. If left empty it will use the currency already configured." } } + }, + "get_price_indices_for_date": { + "name": "Get price indices for date", + "description": "Retrieves the price indices for a specific date.", + "fields": { + "config_entry": { + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::config_entry::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::config_entry::description%]" + }, + "date": { + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::date::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::date::description%]" + }, + "areas": { + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::areas::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::areas::description%]" + }, + "currency": { + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::currency::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::currency::description%]" + }, + "resolution": { + "name": "Resolution", + "description": "Resolution time for the prices, can be any of 15, 30 and 60 minutes." + } + } } }, "exceptions": { diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py index 11e470f1d26..387eaf2e423 100644 --- a/homeassistant/components/notion/entity.py +++ b/homeassistant/components/notion/entity.py @@ -45,9 +45,9 @@ class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sensor.hardware_id)}, manufacturer="Silicon Labs", - model=str(sensor.hardware_revision), name=str(sensor.name).capitalize(), sw_version=sensor.firmware_version, + hw_version=str(sensor.hardware_revision), ) if bridge := self._async_get_bridge(bridge_id): diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index d9d864d10a3..f041b02b6d6 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["aionfty"], "quality_scale": "bronze", - "requirements": ["aiontfy==0.5.3"] + "requirements": ["aiontfy==0.5.4"] } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 3e9d3448af2..1ebd35711ac 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -31,6 +31,7 @@ from homeassistant.loader import async_suggest_report_issue from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 + AMBIGUOUS_UNITS, ATTR_MAX, ATTR_MIN, ATTR_STEP, @@ -39,6 +40,7 @@ from .const import ( # noqa: F401 DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_SET_VALUE, @@ -367,6 +369,15 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return self.entity_description.native_unit_of_measurement return None + @final + @property + def __native_unit_of_measurement_compat(self) -> str | None: + """Process ambiguous units.""" + native_unit_of_measurement = self.native_unit_of_measurement + return AMBIGUOUS_UNITS.get( + native_unit_of_measurement, native_unit_of_measurement + ) + @property @final def unit_of_measurement(self) -> str | None: @@ -374,7 +385,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self._number_option_unit_of_measurement: return self._number_option_unit_of_measurement - native_unit_of_measurement = self.native_unit_of_measurement + native_unit_of_measurement = self.__native_unit_of_measurement_compat # device_class is checked after native_unit_of_measurement since most # of the time we can avoid the device_class check if ( @@ -386,7 +397,9 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if (translation_key := self._unit_of_measurement_translation_key) and ( unit_of_measurement - := self.platform.default_language_platform_translations.get(translation_key) + := self.platform_data.default_language_platform_translations.get( + translation_key + ) ): if native_unit_of_measurement is not None: raise ValueError( @@ -441,7 +454,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if device_class not in UNIT_CONVERTERS: return value - native_unit_of_measurement = self.native_unit_of_measurement + native_unit_of_measurement = self.__native_unit_of_measurement_compat unit_of_measurement = self.unit_of_measurement if native_unit_of_measurement != unit_of_measurement: if TYPE_CHECKING: @@ -470,7 +483,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if value is None or (device_class := self.device_class) not in UNIT_CONVERTERS: return value - native_unit_of_measurement = self.native_unit_of_measurement + native_unit_of_measurement = self.__native_unit_of_measurement_compat unit_of_measurement = self.unit_of_measurement if native_unit_of_measurement != unit_of_measurement: if TYPE_CHECKING: @@ -493,7 +506,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): (number_options := self.registry_entry.options.get(DOMAIN)) and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT)) and (device_class := self.device_class) in UNIT_CONVERTERS - and self.native_unit_of_measurement + and self.__native_unit_of_measurement_compat in UNIT_CONVERTERS[device_class].VALID_UNITS and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS ): diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 1b41146cd2a..76af35adeba 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -8,6 +8,8 @@ from typing import Final import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -78,11 +80,16 @@ class NumberDeviceClass(StrEnum): """Device class for numbers.""" # NumberDeviceClass should be aligned with SensorDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" @@ -130,7 +137,7 @@ class NumberDeviceClass(StrEnum): CONDUCTIVITY = "conductivity" """Conductivity. - Unit of measurement: `S/cm`, `mS/cm`, `µS/cm` + Unit of measurement: `S/cm`, `mS/cm`, `μS/cm` """ CURRENT = "current" @@ -162,7 +169,7 @@ class NumberDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `μs` """ ENERGY = "energy" @@ -240,25 +247,25 @@ class NumberDeviceClass(StrEnum): NITROGEN_DIOXIDE = "nitrogen_dioxide" """Amount of NO2. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ NITROGEN_MONOXIDE = "nitrogen_monoxide" """Amount of NO. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ NITROUS_OXIDE = "nitrous_oxide" """Amount of N2O. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ OZONE = "ozone" """Amount of O3. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ PH = "ph" @@ -270,19 +277,19 @@ class NumberDeviceClass(StrEnum): PM1 = "pm1" """Particulate matter <= 1 μm. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ PM10 = "pm10" """Particulate matter <= 10 μm. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ PM25 = "pm25" """Particulate matter <= 2.5 μm. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ POWER_FACTOR = "power_factor" @@ -332,7 +339,7 @@ class NumberDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var`, `kvar` + Unit of measurement: `mvar`, `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -359,7 +366,7 @@ class NumberDeviceClass(StrEnum): SULPHUR_DIOXIDE = "sulphur_dioxide" """Amount of SO2. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ TEMPERATURE = "temperature" @@ -371,7 +378,7 @@ class NumberDeviceClass(StrEnum): VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" """Amount of VOC. - Unit of measurement: `µg/m³`, `mg/m³` + Unit of measurement: `μg/m³`, `mg/m³` """ VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" @@ -383,7 +390,7 @@ class NumberDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` + Unit of measurement: `V`, `mV`, `μV`, `kV`, `MV` """ VOLUME = "volume" @@ -430,7 +437,7 @@ class NumberDeviceClass(StrEnum): Weight is used instead of mass to fit with every day language. Unit of measurement: `MASS_*` units - - SI / metric: `µg`, `mg`, `g`, `kg` + - SI / metric: `μg`, `mg`, `g`, `kg` - USCS / imperial: `oz`, `lb` """ @@ -452,6 +459,10 @@ class NumberDeviceClass(StrEnum): DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { + NumberDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), NumberDeviceClass.AQI: {None}, NumberDeviceClass.AREA: set(UnitOfArea), @@ -546,3 +557,16 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { NumberDeviceClass.TEMPERATURE: TemperatureConverter, NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, } + +# We translate units that were using using the legacy coding of μ \u00b5 +# to units using recommended coding of μ \u03bc +AMBIGUOUS_UNITS: dict[str | None, str] = { + "\u00b5Sv/h": "μSv/h", # aranet: radiation rate + "\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM, + "\u00b5V": UnitOfElectricPotential.MICROVOLT, + "\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + "\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light + "\u00b5g": UnitOfMass.MICROGRAMS, + "\u00b5s": UnitOfTime.MICROSECONDS, +} diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index dcce09984bd..482b4bc6793 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:ray-vertex" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 998b9ffba38..1e4290f1d75 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -31,6 +31,9 @@ } } }, + "absolute_humidity": { + "name": "[%key:component::sensor::entity_component::absolute_humidity::name%]" + }, "apparent_power": { "name": "[%key:component::sensor::entity_component::apparent_power::name%]" }, diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 2f2c6badc4c..e3460f5a687 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -116,7 +116,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: _LOGGER.debug("NUT Sensors Available: %s", status if status else None) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) if unique_id is None: unique_id = entry.entry_id @@ -199,11 +198,6 @@ async def async_remove_config_entry_device( ) -async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def _manufacturer_from_status(status: dict[str, str]) -> str | None: """Find the best manufacturer value from the status.""" return ( diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 69281e852a8..8a498b99680 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -39,10 +39,12 @@ def _base_schema( base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, - vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED) + ): str, vol.Optional( CONF_PASSWORD, - default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED, ): str, } diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index 1ee85a84caf..608f2c2e495 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -7,6 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["aionut"], + "quality_scale": "platinum", "requirements": ["aionut==4.3.4"], "zeroconf": ["_nut._tcp.local."] } diff --git a/homeassistant/components/nut/quality_scale.yaml b/homeassistant/components/nut/quality_scale.yaml new file mode 100644 index 00000000000..823b1091ef6 --- /dev/null +++ b/homeassistant/components/nut/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom service actions are 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 service actions are registered + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No custom event subscriptions are available + 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 service actions are registered + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters are available + 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: | + The NUT server has no unique id for reliably determining updates + 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: | + Device type integration + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + No repairable issues are raised + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Integration uses NUT protocol and does not communicate via HTTP/HTTPS + strict-typing: done diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json index c32de754782..db3ad6a85f1 100644 --- a/homeassistant/components/nyt_games/manifest.json +++ b/homeassistant/components/nyt_games/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nyt_games", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["nyt_games==0.4.4"] + "requirements": ["nyt_games==0.5.0"] } diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index 3b41e798d22..358be131c93 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -43,10 +43,10 @@ "name": "Disk free" }, "post_processing_jobs": { - "name": "Post processing jobs" + "name": "Post-processing jobs" }, "post_processing_paused": { - "name": "Post processing paused" + "name": "Post-processing paused" }, "queue_size": { "name": "Queue size" diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index 005cf5305d9..25e4062373c 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/octoprint", "iot_class": "local_polling", "loggers": ["pyoctoprintapi"], - "requirements": ["pyoctoprintapi==0.1.12"], + "requirements": ["pyoctoprintapi==0.1.14"], "ssdp": [ { "manufacturer": "The OctoPrint Project", diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 71db1d804c5..5594de48ff5 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -84,6 +84,8 @@ async def async_setup_entry( OctoPrintJobPercentageSensor(coordinator, device_id), OctoPrintEstimatedFinishTimeSensor(coordinator, device_id), OctoPrintStartTimeSensor(coordinator, device_id), + OctoPrintFileNameSensor(coordinator, device_id), + OctoPrintFileSizeSensor(coordinator, device_id), ] async_add_entities(entities) @@ -262,3 +264,61 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase): def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success and self.coordinator.data["printer"] + + +class OctoPrintFileNameSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File", device_id) + + @property + def native_value(self) -> str | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.name or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.name + + +class OctoPrintFileSizeSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + _attr_device_class = SensorDeviceClass.DATA_SIZE + _attr_native_unit_of_measurement = UnitOfInformation.BYTES + _attr_suggested_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File Size", device_id) + + @property + def native_value(self) -> int | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.size or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.size diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index c828ee0af9f..091e58dbe7f 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -4,15 +4,21 @@ from __future__ import annotations import asyncio import logging +from types import MappingProxyType import httpx import ollama -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import get_default_context from .const import ( @@ -22,6 +28,8 @@ from .const import ( CONF_NUM_CTX, CONF_PROMPT, CONF_THINK, + DEFAULT_AI_TASK_NAME, + DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN, ) @@ -40,10 +48,18 @@ __all__ = [ ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) + +type OllamaConfigEntry = ConfigEntry[ollama.AsyncClient] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Ollama.""" + await async_migrate_integration(hass) + return True + + +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()) @@ -53,9 +69,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (TimeoutError, httpx.ConnectError) as err: raise ConfigEntryNotReady(err) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client - + entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -63,5 +81,235 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Ollama.""" if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False - hass.data[DOMAIN].pop(entry.entry_id) return True + + +async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) + if not any(entry.version == 1 for entry in entries): + return + + url_entries: dict[str, tuple[ConfigEntry, bool]] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + # Create subentry with model from entry.data and options from entry.options + subentry_data = entry.options.copy() + subentry_data[CONF_MODEL] = entry.data[CONF_MODEL] + + subentry = ConfigSubentry( + data=MappingProxyType(subentry_data), + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_URL] not in url_entries: + use_existing = True + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_URL] == entry.data[CONF_URL] + ) + url_entries[entry.data[CONF_URL]] = (entry, all_disabled) + + parent_entry, all_disabled = url_entries[entry.data[CONF_URL]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + + conversation_entity_id = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + _add_ai_task_subentry(hass, entry) + hass.config_entries.async_update_entry( + entry, + title=DEFAULT_NAME, + # Update parent entry to only keep URL, remove model + data={CONF_URL: entry.data[CONF_URL]}, + options={}, + version=3, + minor_version=3, + ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 3: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + if entry.version == 2 and entry.minor_version == 2: + # Update subentries to include the model + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + updated_data = dict(subentry.data) + updated_data[CONF_MODEL] = entry.data[CONF_MODEL] + + hass.config_entries.async_update_subentry( + entry, subentry, data=MappingProxyType(updated_data) + ) + + # Update main entry to remove model and bump version + hass.config_entries.async_update_entry( + entry, + data={CONF_URL: entry.data[CONF_URL]}, + version=3, + minor_version=1, + ) + + if entry.version == 3 and entry.minor_version == 1: + _add_ai_task_subentry(hass, entry) + hass.config_entries.async_update_entry(entry, minor_version=2) + + if entry.version == 3 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OllamaConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + # Add AI Task subentry with default options. We can only create a new + # subentry if we can find an existing model in the entry. The model + # was removed in the previous migration step, so we need to + # check the subentries for an existing model. + existing_model = next( + iter( + model + for subentry in entry.subentries.values() + if (model := subentry.data.get(CONF_MODEL)) is not None + ), + None, + ) + if existing_model: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType({CONF_MODEL: existing_model}), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/ollama/ai_task.py b/homeassistant/components/ollama/ai_task.py new file mode 100644 index 00000000000..43c50abd16a --- /dev/null +++ b/homeassistant/components/ollama/ai_task.py @@ -0,0 +1,80 @@ +"""AI Task integration for Ollama.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +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.util.json import json_loads + +from .entity import OllamaBaseLLMEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OllamaTaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OllamaTaskEntity( + ai_task.AITaskEntity, + OllamaBaseLLMEntity, +): + """Ollama AI Task entity.""" + + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + _LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError("Error with Ollama structured response") from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index b94a0fc621d..68deb00d205 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -14,13 +14,15 @@ import voluptuous as vol from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_LLM_HASS_API, CONF_URL -from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm +from homeassistant.const import 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 ( BooleanSelector, NumberSelector, @@ -36,6 +38,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.util.ssl import get_default_context +from . import OllamaConfigEntry from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, @@ -43,6 +46,8 @@ from .const import ( CONF_NUM_CTX, CONF_PROMPT, CONF_THINK, + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DEFAULT_KEEP_ALIVE, DEFAULT_MAX_HISTORY, DEFAULT_MODEL, @@ -70,40 +75,43 @@ STEP_USER_DATA_SCHEMA = vol.Schema( class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" - VERSION = 1 + VERSION = 3 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize config flow.""" self.url: str | None = None - self.model: str | None = None - self.client: ollama.AsyncClient | None = None - self.download_task: asyncio.Task | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - user_input = user_input or {} - self.url = user_input.get(CONF_URL, self.url) - self.model = user_input.get(CONF_MODEL, self.model) - - if self.url is None: + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, last_step=False + step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) errors = {} + url = user_input[CONF_URL] + + self._async_abort_entries_match({CONF_URL: url}) try: - self.client = ollama.AsyncClient( - host=self.url, verify=get_default_context() + url = cv.url(url) + except vol.Invalid: + errors["base"] = "invalid_url" + 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 with asyncio.timeout(DEFAULT_TIMEOUT): - response = await self.client.list() - downloaded_models: set[str] = { - model_info["model"] for model_info in response.get("models", []) - } + 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: @@ -112,10 +120,72 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): if errors: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) - if self.model is None: + return self.async_create_entry( + title=url, + data={CONF_URL: url}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return { + "conversation": OllamaSubentryFlowHandler, + "ai_task_data": OllamaSubentryFlowHandler, + } + + +class OllamaSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing Ollama subentries.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self._name: str | None = None + self._model: str | None = None + self.download_task: asyncio.Task | None = None + self._config_data: dict[str, Any] | None = None + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + @property + def _client(self) -> ollama.AsyncClient: + """Return the Ollama client.""" + entry: OllamaConfigEntry = self._get_entry() + return entry.runtime_data + + async def async_step_set_options( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle model selection and configuration step.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is None: + # Get available models from Ollama server + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + except (TimeoutError, httpx.ConnectError, httpx.HTTPError): + _LOGGER.exception("Failed to get models from Ollama server") + return self.async_abort(reason="cannot_connect") + # Show models that have been downloaded first, followed by all known # models (only latest tags). models_to_list = [ @@ -126,44 +196,73 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): for m in sorted(MODEL_NAMES) if m not in downloaded_models ] - model_step_schema = vol.Schema( - { - vol.Required( - CONF_MODEL, description={"suggested_value": DEFAULT_MODEL} - ): SelectSelector( - SelectSelectorConfig(options=models_to_list, custom_value=True) - ), - } - ) + + if self._is_new: + options = {} + else: + options = self._get_reconfigure_subentry().data.copy() return self.async_show_form( - step_id="user", - data_schema=model_step_schema, + step_id="set_options", + data_schema=vol.Schema( + ollama_config_option_schema( + self.hass, + self._is_new, + self._subentry_type, + options, + models_to_list, + ) + ), ) - if self.model not in downloaded_models: - # Ollama server needs to download model first - return await self.async_step_download() + self._model = user_input[CONF_MODEL] + if self._is_new: + self._name = user_input.pop(CONF_NAME) - return self.async_create_entry( - title=_get_title(self.model), - data={CONF_URL: self.url, CONF_MODEL: self.model}, + # Check if model needs to be downloaded + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + currently_downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + + if self._model not in currently_downloaded_models: + # Store the user input to use after download + self._config_data = user_input + # Ollama server needs to download model first + return await self.async_step_download() + except Exception: + _LOGGER.exception("Failed to check model availability") + return self.async_abort(reason="cannot_connect") + + # Model is already downloaded, create/update the entry + if self._is_new: + return self.async_create_entry( + title=self._name, + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, ) async def async_step_download( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step to wait for Ollama server to download a model.""" - assert self.model is not None - assert self.client is not None + assert self._model is not None if self.download_task is None: # Tell Ollama server to pull the model. # The task will block until the model and metadata are fully # downloaded. self.download_task = self.hass.async_create_background_task( - self.client.pull(self.model), - f"Downloading {self.model}", + self._client.pull(self._model), + f"Downloading {self._model}", ) if self.download_task.done(): @@ -179,122 +278,136 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): progress_task=self.download_task, ) - async def async_step_finish( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step after model downloading has succeeded.""" - assert self.url is not None - assert self.model is not None - - return self.async_create_entry( - title=_get_title(self.model), - data={CONF_URL: self.url, CONF_MODEL: self.model}, - ) - async def async_step_failed( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step after model downloading has failed.""" return self.async_abort(reason="download_failed") - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return OllamaOptionsFlow(config_entry) - - -class OllamaOptionsFlow(OptionsFlow): - """Ollama options flow.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.url: str = config_entry.data[CONF_URL] - self.model: str = config_entry.data[CONF_MODEL] - - async def async_step_init( + async def async_step_finish( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry( - title=_get_title(self.model), data=user_input - ) + ) -> SubentryFlowResult: + """Step after model downloading has succeeded.""" + assert self._config_data is not None - options: Mapping[str, Any] = self.config_entry.options or {} - schema = ollama_config_option_schema(self.hass, options) - return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), + # Model download completed, create/update the entry with stored config + if self._is_new: + return self.async_create_entry( + title=self._name, + data=self._config_data, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=self._config_data, ) + async_step_user = async_step_set_options + async_step_reconfigure = async_step_set_options + def ollama_config_option_schema( - hass: HomeAssistant, options: Mapping[str, Any] + hass: HomeAssistant, + is_new: bool, + subentry_type: str, + options: Mapping[str, Any], + models_to_list: list[SelectOptionDict], ) -> dict: """Ollama options schema.""" - hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label=api.name, - value=api.id, + if is_new: + if subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME + + schema: dict = { + vol.Required(CONF_NAME, default=default_name): str, + } + else: + schema = {} + + schema.update( + { + vol.Required( + CONF_MODEL, + description={"suggested_value": options.get(CONF_MODEL, DEFAULT_MODEL)}, + ): SelectSelector( + SelectSelectorConfig(options=models_to_list, custom_value=True) + ), + } + ) + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": options.get(CONF_LLM_HASS_API)}, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ], + multiple=True, + ) + ), + } ) - for api in llm.async_get_apis(hass) - ] - - return { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + schema.update( + { + vol.Optional( + CONF_NUM_CTX, + description={ + "suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX) + }, + ): NumberSelector( + NumberSelectorConfig( + min=MIN_NUM_CTX, + max=MAX_NUM_CTX, + step=1, + mode=NumberSelectorMode.BOX, ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Optional( - CONF_NUM_CTX, - description={"suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, - ): NumberSelector( - NumberSelectorConfig( - min=MIN_NUM_CTX, max=MAX_NUM_CTX, step=1, mode=NumberSelectorMode.BOX - ) - ), - vol.Optional( - CONF_MAX_HISTORY, - description={ - "suggested_value": options.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY) - }, - ): NumberSelector( - NumberSelectorConfig( - min=0, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX - ) - ), - vol.Optional( - CONF_KEEP_ALIVE, - description={ - "suggested_value": options.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE) - }, - ): NumberSelector( - NumberSelectorConfig( - min=-1, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX - ) - ), - vol.Optional( - CONF_THINK, - description={ - "suggested_value": options.get("think", DEFAULT_THINK), - }, - ): BooleanSelector(), - } + ), + vol.Optional( + CONF_MAX_HISTORY, + description={ + "suggested_value": options.get( + CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY + ) + }, + ): NumberSelector( + NumberSelectorConfig( + min=0, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Optional( + CONF_KEEP_ALIVE, + description={ + "suggested_value": options.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE) + }, + ): NumberSelector( + NumberSelectorConfig( + min=-1, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Optional( + CONF_THINK, + description={ + "suggested_value": options.get("think", DEFAULT_THINK), + }, + ): BooleanSelector(), + } + ) - -def _get_title(model: str) -> str: - """Get title for config entry.""" - if model.endswith(":latest"): - model = model.split(":", maxsplit=1)[0] - - return model + return schema diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index ebace6404b2..093e20f5140 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -2,6 +2,8 @@ DOMAIN = "ollama" +DEFAULT_NAME = "Ollama" + CONF_MODEL = "model" CONF_PROMPT = "prompt" CONF_THINK = "think" @@ -156,4 +158,11 @@ MODEL_NAMES = [ # https://ollama.com/library "yi", "zephyr", ] -DEFAULT_MODEL = "llama3.2:latest" +DEFAULT_MODEL = "qwen3:4b" + +DEFAULT_CONVERSATION_NAME = "Ollama Conversation" +DEFAULT_AI_TASK_NAME = "Ollama AI Task" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_MAX_HISTORY: DEFAULT_MAX_HISTORY, +} diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 1717d0b24b2..cba8559e826 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,189 +2,48 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable -import json -import logging -from typing import Any, Literal +from typing import Literal -import ollama -from voluptuous_openapi import convert - -from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_KEEP_ALIVE, - CONF_MAX_HISTORY, - CONF_MODEL, - CONF_NUM_CTX, - CONF_PROMPT, - CONF_THINK, - DEFAULT_KEEP_ALIVE, - DEFAULT_MAX_HISTORY, - DEFAULT_NUM_CTX, - DOMAIN, -) -from .models import MessageHistory, MessageRole - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -_LOGGER = logging.getLogger(__name__) +from . import OllamaConfigEntry +from .const import CONF_PROMPT, DOMAIN +from .entity import OllamaBaseLLMEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OllamaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = OllamaConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue - -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> dict[str, Any]: - """Format tool specification.""" - tool_spec = { - "name": tool.name, - "parameters": convert(tool.parameters, custom_serializer=custom_serializer), - } - if tool.description: - tool_spec["description"] = tool.description - return {"type": "function", "function": tool_spec} - - -def _fix_invalid_arguments(value: Any) -> Any: - """Attempt to repair incorrectly formatted json function arguments. - - Small models (for example llama3.1 8B) may produce invalid argument values - which we attempt to repair here. - """ - if not isinstance(value, str): - return value - if (value.startswith("[") and value.endswith("]")) or ( - value.startswith("{") and value.endswith("}") - ): - try: - return json.loads(value) - except json.decoder.JSONDecodeError: - pass - return value - - -def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: - """Rewrite ollama tool arguments. - - This function improves tool use quality by fixing common mistakes made by - small local tool use models. This will repair invalid json arguments and - omit unnecessary arguments with empty values that will fail intent parsing. - """ - return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} - - -def _convert_content( - chat_content: ( - conversation.Content - | conversation.ToolResultContent - | conversation.AssistantContent - ), -) -> ollama.Message: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return ollama.Message( - role=MessageRole.TOOL.value, - content=json.dumps(chat_content.tool_result), + async_add_entities( + [OllamaConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, ) - if isinstance(chat_content, conversation.AssistantContent): - return ollama.Message( - role=MessageRole.ASSISTANT.value, - content=chat_content.content, - tool_calls=[ - ollama.Message.ToolCall( - function=ollama.Message.ToolCall.Function( - name=tool_call.tool_name, - arguments=tool_call.tool_args, - ) - ) - for tool_call in chat_content.tool_calls or () - ], - ) - if isinstance(chat_content, conversation.UserContent): - return ollama.Message( - role=MessageRole.USER.value, - content=chat_content.content, - ) - if isinstance(chat_content, conversation.SystemContent): - return ollama.Message( - role=MessageRole.SYSTEM.value, - content=chat_content.content, - ) - raise TypeError(f"Unexpected content type: {type(chat_content)}") - - -async def _transform_stream( - result: AsyncGenerator[ollama.Message], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - An Ollama streaming response may come in chunks like this: - - response: message=Message(role="assistant", content="Paris") - response: message=Message(role="assistant", content=".") - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - response: message=Message(role="assistant", tool_calls=[...]) - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - - This generator conforms to the chatlog delta stream expectations in that it - yields deltas, then the role only once the response is done. - """ - - new_msg = True - async for response in result: - _LOGGER.debug("Received response: %s", response) - response_message = response["message"] - chunk: conversation.AssistantContentDeltaDict = {} - if new_msg: - new_msg = False - chunk["role"] = "assistant" - if (tool_calls := response_message.get("tool_calls")) is not None: - chunk["tool_calls"] = [ - llm.ToolInput( - tool_name=tool_call["function"]["name"], - tool_args=_parse_tool_args(tool_call["function"]["arguments"]), - ) - for tool_call in tool_calls - ] - if (content := response_message.get("content")) is not None: - chunk["content"] = content - if response_message.get("done"): - new_msg = True - yield chunk class OllamaConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OllamaBaseLLMEntity, ): """Ollama conversation agent.""" - _attr_has_entity_name = True _attr_supports_streaming = True - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - - # conversation id -> message history - self._attr_name = entry.title - self._attr_unique_id = entry.entry_id - if self.entry.options.get(CONF_LLM_HASS_API): + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -192,13 +51,7 @@ class OllamaConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -216,7 +69,7 @@ class OllamaConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - settings = {**self.entry.data, **self.entry.options} + settings = {**self.entry.data, **self.subentry.data} try: await chat_log.async_provide_llm_data( @@ -230,105 +83,4 @@ class OllamaConversationEntity( await self._async_handle_chat_log(chat_log) - # Create intent response - intent_response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - raise TypeError( - f"Unexpected last message type: {type(chat_log.content[-1])}" - ) - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - settings = {**self.entry.data, **self.entry.options} - - client = self.hass.data[DOMAIN][self.entry.entry_id] - model = settings[CONF_MODEL] - - tools: list[dict[str, Any]] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - message_history: MessageHistory = MessageHistory( - [_convert_content(content) for content in chat_log.content] - ) - max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) - self._trim_history(message_history, max_messages) - - # Get response - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - response_generator = await client.chat( - model=model, - # Make a copy of the messages because we mutate the list later - messages=list(message_history.messages), - tools=tools, - stream=True, - # keep_alive requires specifying unit. In this case, seconds - keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", - options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, - think=settings.get(CONF_THINK), - ) - except (ollama.RequestError, ollama.ResponseError) as err: - _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - raise HomeAssistantError( - f"Sorry, I had a problem talking to the Ollama server: {err}" - ) from err - - message_history.messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(response_generator) - ) - ] - ) - - if not chat_log.unresponded_tool_results: - break - - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history. - - This sets the max history to allow a configurable size history may take - up in the context window. - - Note that some messages in the history may not be from ollama only, and - may come from other anents, so the assumptions here may not strictly hold, - but generally should be effective. - """ - if max_messages < 1: - # Keep all messages - return - - # Ignore the in progress user message - num_previous_rounds = message_history.num_user_messages - 1 - if num_previous_rounds >= max_messages: - # Trim history but keep system prompt (first message). - # Every other message should be an assistant message, so keep 2x - # message objects. Also keep the last in progress user message - num_keep = 2 * max_messages + 1 - drop_index = len(message_history.messages) - num_keep - message_history.messages = [ - message_history.messages[0], - *message_history.messages[drop_index:], - ] - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py new file mode 100644 index 00000000000..2581698e185 --- /dev/null +++ b/homeassistant/components/ollama/entity.py @@ -0,0 +1,286 @@ +"""Base entity for the Ollama integration.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterator, Callable +import json +import logging +from typing import Any + +import ollama +import voluptuous as vol +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OllamaConfigEntry +from .const import ( + CONF_KEEP_ALIVE, + CONF_MAX_HISTORY, + CONF_MODEL, + CONF_NUM_CTX, + CONF_THINK, + DEFAULT_KEEP_ALIVE, + DEFAULT_MAX_HISTORY, + DEFAULT_NUM_CTX, + DOMAIN, +) +from .models import MessageHistory, MessageRole + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> dict[str, Any]: + """Format tool specification.""" + tool_spec = { + "name": tool.name, + "parameters": convert(tool.parameters, custom_serializer=custom_serializer), + } + if tool.description: + tool_spec["description"] = tool.description + return {"type": "function", "function": tool_spec} + + +def _fix_invalid_arguments(value: Any) -> Any: + """Attempt to repair incorrectly formatted json function arguments. + + Small models (for example llama3.1 8B) may produce invalid argument values + which we attempt to repair here. + """ + if not isinstance(value, str): + return value + if (value.startswith("[") and value.endswith("]")) or ( + value.startswith("{") and value.endswith("}") + ): + try: + return json.loads(value) + except json.decoder.JSONDecodeError: + pass + return value + + +def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: + """Rewrite ollama tool arguments. + + This function improves tool use quality by fixing common mistakes made by + small local tool use models. This will repair invalid json arguments and + omit unnecessary arguments with empty values that will fail intent parsing. + """ + return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} + + +def _convert_content( + chat_content: ( + conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent + ), +) -> ollama.Message: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return ollama.Message( + role=MessageRole.TOOL.value, + content=json.dumps(chat_content.tool_result), + ) + if isinstance(chat_content, conversation.AssistantContent): + return ollama.Message( + role=MessageRole.ASSISTANT.value, + content=chat_content.content, + tool_calls=[ + ollama.Message.ToolCall( + function=ollama.Message.ToolCall.Function( + name=tool_call.tool_name, + arguments=tool_call.tool_args, + ) + ) + for tool_call in chat_content.tool_calls or () + ], + ) + if isinstance(chat_content, conversation.UserContent): + images: list[ollama.Image] = [] + for attachment in chat_content.attachments or (): + if not attachment.mime_type.startswith("image/"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_attachment_type", + ) + images.append(ollama.Image(value=attachment.path)) + return ollama.Message( + role=MessageRole.USER.value, + content=chat_content.content, + images=images or None, + ) + if isinstance(chat_content, conversation.SystemContent): + return ollama.Message( + role=MessageRole.SYSTEM.value, + content=chat_content.content, + ) + raise TypeError(f"Unexpected content type: {type(chat_content)}") + + +async def _transform_stream( + result: AsyncIterator[ollama.ChatResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + An Ollama streaming response may come in chunks like this: + + response: message=Message(role="assistant", content="Paris") + response: message=Message(role="assistant", content=".") + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", tool_calls=[...]) + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + + This generator conforms to the chatlog delta stream expectations in that it + yields deltas, then the role only once the response is done. + """ + + new_msg = True + async for response in result: + _LOGGER.debug("Received response: %s", response) + response_message = response["message"] + chunk: conversation.AssistantContentDeltaDict = {} + if new_msg: + new_msg = False + chunk["role"] = "assistant" + if (tool_calls := response_message.get("tool_calls")) is not None: + chunk["tool_calls"] = [ + llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=_parse_tool_args(tool_call["function"]["arguments"]), + ) + for tool_call in tool_calls + ] + if (content := response_message.get("content")) is not None: + chunk["content"] = content + if response_message.get("done"): + new_msg = True + yield chunk + + +class OllamaBaseLLMEntity(Entity): + """Ollama base LLM entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_unique_id = subentry.subentry_id + + model, _, version = subentry.data[CONF_MODEL].partition(":") + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Ollama", + model=model, + sw_version=version or "latest", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure: vol.Schema | None = None, + ) -> None: + """Generate an answer for the chat log.""" + settings = {**self.entry.data, **self.subentry.data} + + client = self.entry.runtime_data + model = settings[CONF_MODEL] + + tools: list[dict[str, Any]] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + message_history: MessageHistory = MessageHistory( + [_convert_content(content) for content in chat_log.content] + ) + max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) + self._trim_history(message_history, max_messages) + + output_format: dict[str, Any] | None = None + if structure: + output_format = convert( + structure, + custom_serializer=( + chat_log.llm_api.custom_serializer + if chat_log.llm_api + else llm.selector_serializer + ), + ) + + # Get response + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + response_generator = await client.chat( + model=model, + # Make a copy of the messages because we mutate the list later + messages=list(message_history.messages), + tools=tools, + stream=True, + # keep_alive requires specifying unit. In this case, seconds + keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", + options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, + think=settings.get(CONF_THINK), + format=output_format, + ) + except (ollama.RequestError, ollama.ResponseError) as err: + _LOGGER.error("Unexpected error talking to Ollama server: %s", err) + raise HomeAssistantError( + f"Sorry, I had a problem talking to the Ollama server: {err}" + ) from err + + message_history.messages.extend( + [ + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(response_generator) + ) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: + """Trims excess messages from a single history. + + This sets the max history to allow a configurable size history may take + up in the context window. + + Note that some messages in the history may not be from ollama only, and + may come from other anents, so the assumptions here may not strictly hold, + but generally should be effective. + """ + if max_messages < 1: + # Keep all messages + return + + # Ignore the in progress user message + num_previous_rounds = message_history.num_user_messages - 1 + if num_previous_rounds >= max_messages: + # Trim history but keep system prompt (first message). + # Every other message should be an assistant message, so keep 2x + # message objects. Also keep the last in progress user message + num_keep = 2 * max_messages + 1 + drop_index = len(message_history.messages) - num_keep + message_history.messages = [ + message_history.messages[0], + *message_history.messages[drop_index:], + ] diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index c60b0ef7ebd..9ec03cef69a 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -3,43 +3,101 @@ "step": { "user": { "data": { - "url": "[%key:common::config_flow::data::url%]", - "model": "Model" + "url": "[%key:common::config_flow::data::url%]" } - }, - "download": { - "title": "Downloading model" } }, "abort": { - "download_failed": "Model downloading failed" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { + "invalid_url": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "progress": { - "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "max_history": "Max history messages", - "num_ctx": "Context window size", - "keep_alive": "Keep alive", - "think": "Think before responding" + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + "step": { + "set_options": { + "data": { + "model": "Model", + "name": "[%key:common::config_flow::data::name%]", + "prompt": "[%key:common::config_flow::data::prompt%]", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "max_history": "Max history messages", + "num_ctx": "Context window size", + "keep_alive": "Keep alive", + "think": "Think before responding" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "keep_alive": "Duration in seconds for Ollama to keep model in memory. -1 = indefinite, 0 = never.", + "num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.", + "think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency." + } }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "keep_alive": "Duration in seconds for Ollama to keep model in memory. -1 = indefinite, 0 = never.", - "num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.", - "think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency." + "download": { + "title": "Downloading model" } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "Failed to add agent. The configuration is disabled.", + "download_failed": "Model downloading failed", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "progress": { + "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } + }, + "ai_task_data": { + "initiate_flow": { + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" + }, + "entry_type": "AI task", + "step": { + "set_options": { + "data": { + "model": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::model%]", + "name": "[%key:common::config_flow::data::name%]", + "prompt": "[%key:common::config_flow::data::prompt%]", + "max_history": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::max_history%]", + "num_ctx": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::num_ctx%]", + "keep_alive": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::keep_alive%]", + "think": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::think%]" + }, + "data_description": { + "prompt": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::prompt%]", + "keep_alive": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::keep_alive%]", + "num_ctx": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::num_ctx%]", + "think": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::think%]" + } + }, + "download": { + "title": "[%key:component::ollama::config_subentries::conversation::step::download::title%]" + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "[%key:component::ollama::config_subentries::conversation::abort::entry_not_loaded%]", + "download_failed": "[%key:component::ollama::config_subentries::conversation::abort::download_failed%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "progress": { + "download": "[%key:component::ollama::config_subentries::conversation::progress::download%]" + } + } + }, + "exceptions": { + "unsupported_attachment_type": { + "message": "Ollama only supports image attachments in user content, but received non-image attachment." } } } diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a897d04562f..a89a98a7fcf 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -317,7 +317,7 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView): - """Get backup info view.""" + """View to wait for an integration.""" url = "/api/onboarding/integration/wait" name = "api:onboarding:integration:wait" diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index da5ccae11a5..42e65bd0db2 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -137,9 +137,11 @@ class OndiloICO(CoordinatorEntity[OndiloIcoMeasuresCoordinator], SensorEntity): super().__init__(coordinator) self.entity_description = description self._pool_id = pool_id - self._attr_unique_id = f"{pool_data.ico['serial_number']}-{description.key}" + serial_number = pool_data.ico["serial_number"] + self._attr_unique_id = f"{serial_number}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, pool_data.ico["serial_number"])}, + identifiers={(DOMAIN, serial_number)}, + serial_number=serial_number, ) @property diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index c77d87d91b9..396539d93e3 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -39,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b onewire_hub.schedule_scan_for_new_devices() - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - return True @@ -59,11 +57,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) - - -async def options_update_listener( - hass: HomeAssistant, entry: OneWireConfigEntry -) -> None: - """Handle options update.""" - _LOGGER.debug("Configuration options updated, reloading OneWire integration") - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 7d6b3e2c019..c1d34bad60e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -41,6 +41,13 @@ class OneWireBinarySensorEntityDescription( DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { + "05": ( + OneWireBinarySensorEntityDescription( + key="sensed", + entity_registry_enabled_default=False, + translation_key="sensed", + ), + ), "12": tuple( OneWireBinarySensorEntityDescription( key=f"sensed.{device_key}", diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 2099d9aabb5..0f2a2b6c51c 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pyownet import protocol import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -160,7 +164,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return OnewireOptionsFlowHandler(config_entry) -class OnewireOptionsFlowHandler(OptionsFlow): +class OnewireOptionsFlowHandler(OptionsFlowWithReload): """Handle OneWire Config options.""" configurable_devices: dict[str, str] diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index 64c7a8c3ebb..c66ec3bef15 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -53,8 +53,6 @@ class OneWireEntity(Entity): """Return the state attributes of the entity.""" return { "device_file": self._device_file, - # raw_value attribute is deprecated and can be removed in 2025.8 - "raw_value": self._value_raw, } def _read_value(self) -> str: diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 5e7719673b1..c77f2933fe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -37,6 +37,9 @@ }, "entity": { "binary_sensor": { + "sensed": { + "name": "Sensed" + }, "sensed_id": { "name": "Sensed {id}" }, diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index 67ed4162778..a4d1ec8f175 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -17,7 +17,7 @@ from .const import ( InputSource, ListeningMode, ) -from .receiver import Receiver, async_interview +from .receiver import ReceiverManager, async_interview from .services import DATA_MP_ENTITIES, async_setup_services _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) class OnkyoData: """Config Entry data.""" - receiver: Receiver + manager: ReceiverManager sources: dict[InputSource, str] sound_modes: dict[ListeningMode, str] @@ -47,15 +47,17 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bool: """Set up the Onkyo config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) host = entry.data[CONF_HOST] - info = await async_interview(host) + try: + info = await async_interview(host) + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc if info is None: raise ConfigEntryNotReady(f"Unable to connect to: {host}") - receiver = await Receiver.async_create(info) + manager = ReceiverManager(hass, entry, info) sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} @@ -63,11 +65,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} - entry.runtime_data = OnkyoData(receiver, sources, sound_modes) + entry.runtime_data = OnkyoData(manager, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await receiver.conn.connect() + if error := await manager.start(): + try: + await error + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc return True @@ -76,14 +82,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo """Unload Onkyo config entry.""" del hass.data[DATA_MP_ENTITIES][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + entry.runtime_data.manager.start_unloading() - receiver = entry.runtime_data.receiver - receiver.conn.close() - - return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: OnkyoConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 85ff0de3251..75b0f92043d 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -4,15 +4,15 @@ from collections.abc import Mapping import logging from typing import Any +from aioonkyo import ReceiverInfo import voluptuous as vol from yarl import URL from homeassistant.config_entries import ( SOURCE_RECONFIGURE, - ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -29,6 +29,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from . import OnkyoConfigEntry from .const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -41,19 +42,20 @@ from .const import ( InputSource, ListeningMode, ) -from .receiver import ReceiverInfo, async_discover, async_interview +from .receiver import async_discover, async_interview +from .util import get_meaning _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_DEFAULT: dict[str, str] = {} -LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_DEFAULT: list[InputSource] = [] +LISTENING_MODES_DEFAULT: list[ListeningMode] = [] INPUT_SOURCES_ALL_MEANINGS = { - input_source.value_meaning: input_source for input_source in InputSource + get_meaning(input_source): input_source for input_source in InputSource } LISTENING_MODES_ALL_MEANINGS = { - listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode + get_meaning(listening_mode): listening_mode for listening_mode in ListeningMode } STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_RECONFIGURE_SCHEMA = vol.Schema( @@ -91,6 +93,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" + _LOGGER.debug("Config flow start user") return self.async_show_menu( step_id="user", menu_options=["manual", "eiscp_discovery"] ) @@ -103,10 +106,10 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: host = user_input[CONF_HOST] - _LOGGER.debug("Config flow start manual: %s", host) + _LOGGER.debug("Config flow manual: %s", host) try: info = await async_interview(host) - except Exception: + except OSError: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -156,8 +159,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Config flow start eiscp discovery") try: - infos = await async_discover() - except Exception: + infos = list(await async_discover(self.hass)) + except OSError: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -303,8 +306,14 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_INPUT_SOURCES: [ + get_meaning(input_source) + for input_source in INPUT_SOURCES_DEFAULT + ], + OPTION_LISTENING_MODES: [ + get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + ], } else: entry_options = reconfigure_entry.options @@ -325,11 +334,12 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of the receiver.""" + _LOGGER.debug("Config flow start reconfigure") return await self.async_step_manual() @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: OnkyoConfigEntry) -> OptionsFlowWithReload: """Return the options flow.""" return OnkyoOptionsFlowHandler() @@ -357,7 +367,7 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema( ) -class OnkyoOptionsFlowHandler(OptionsFlow): +class OnkyoOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Onkyo.""" _data: dict[str, Any] @@ -372,7 +382,10 @@ class OnkyoOptionsFlowHandler(OptionsFlow): entry_options: Mapping[str, Any] = self.config_entry.options entry_options = { - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_LISTENING_MODES: { + listening_mode.value: get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + }, **entry_options, } @@ -416,11 +429,11 @@ class OnkyoOptionsFlowHandler(OptionsFlow): suggested_values = { OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning + get_meaning(InputSource(input_source)) for input_source in entry_options[OPTION_INPUT_SOURCES] ], OPTION_LISTENING_MODES: [ - ListeningMode(listening_mode).value_meaning + get_meaning(ListeningMode(listening_mode)) for listening_mode in entry_options[OPTION_LISTENING_MODES] ], } @@ -463,13 +476,13 @@ class OnkyoOptionsFlowHandler(OptionsFlow): input_sources_schema_dict: dict[Any, Selector] = {} for input_source, input_source_name in self._input_sources.items(): input_sources_schema_dict[ - vol.Required(input_source.value_meaning, default=input_source_name) + vol.Required(get_meaning(input_source), default=input_source_name) ] = TextSelector() listening_modes_schema_dict: dict[Any, Selector] = {} for listening_mode, listening_mode_name in self._listening_modes.items(): listening_modes_schema_dict[ - vol.Required(listening_mode.value_meaning, default=listening_mode_name) + vol.Required(get_meaning(listening_mode), default=listening_mode_name) ] = TextSelector() return self.async_show_form( diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index 851d80c5100..4f5be4238b4 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -1,10 +1,9 @@ """Constants for the Onkyo integration.""" -from enum import Enum import typing -from typing import Literal, Self +from typing import Literal -import pyeiscp +from aioonkyo import HDMIOutputParam, InputSourceParam, ListeningModeParam, Zone DOMAIN = "onkyo" @@ -21,214 +20,37 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args( OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 - -class EnumWithMeaning(Enum): - """Enum with meaning.""" - - value_meaning: str - - def __new__(cls, value: str) -> Self: - """Create enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = cls._get_meanings()[value] - - return obj - - @staticmethod - def _get_meanings() -> dict[str, str]: - raise NotImplementedError - - OPTION_INPUT_SOURCES = "input_sources" OPTION_LISTENING_MODES = "listening_modes" -_INPUT_SOURCE_MEANINGS = { - "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", - "01": "VIDEO2 ··· CBL/SAT", - "02": "VIDEO3 ··· GAME/TV ··· GAME", - "03": "VIDEO4 ··· AUX", - "04": "VIDEO5 ··· AUX2 ··· GAME2", - "05": "VIDEO6 ··· PC", - "06": "VIDEO7", - "07": "HIDDEN1 ··· EXTRA1", - "08": "HIDDEN2 ··· EXTRA2", - "09": "HIDDEN3 ··· EXTRA3", - "10": "DVD ··· BD/DVD", - "11": "STRM BOX", - "12": "TV", - "20": "TAPE ··· TV/TAPE", - "21": "TAPE2", - "22": "PHONO", - "23": "CD ··· TV/CD", - "24": "FM", - "25": "AM", - "26": "TUNER", - "27": "MUSIC SERVER ··· P4S ··· DLNA", - "28": "INTERNET RADIO ··· IRADIO FAVORITE", - "29": "USB ··· USB(FRONT)", - "2A": "USB(REAR)", - "2B": "NETWORK ··· NET", - "2D": "AIRPLAY", - "2E": "BLUETOOTH", - "2F": "USB DAC IN", - "30": "MULTI CH", - "31": "XM", - "32": "SIRIUS", - "33": "DAB", - "40": "UNIVERSAL PORT", - "41": "LINE", - "42": "LINE2", - "44": "OPTICAL", - "45": "COAXIAL", - "55": "HDMI 5", - "56": "HDMI 6", - "57": "HDMI 7", - "80": "MAIN SOURCE", +InputSource = InputSourceParam +ListeningMode = ListeningModeParam +HDMIOutput = HDMIOutputParam + +ZONES = { + Zone.MAIN: "Main", + Zone.ZONE2: "Zone 2", + Zone.ZONE3: "Zone 3", + Zone.ZONE4: "Zone 4", } -class InputSource(EnumWithMeaning): - """Receiver input source.""" - - DVR = "00" - CBL = "01" - GAME = "02" - AUX = "03" - GAME2 = "04" - PC = "05" - VIDEO7 = "06" - EXTRA1 = "07" - EXTRA2 = "08" - EXTRA3 = "09" - DVD = "10" - STRM_BOX = "11" - TV = "12" - TAPE = "20" - TAPE2 = "21" - PHONO = "22" - CD = "23" - FM = "24" - AM = "25" - TUNER = "26" - MUSIC_SERVER = "27" - INTERNET_RADIO = "28" - USB = "29" - USB_REAR = "2A" - NETWORK = "2B" - AIRPLAY = "2D" - BLUETOOTH = "2E" - USB_DAC_IN = "2F" - MULTI_CH = "30" - XM = "31" - SIRIUS = "32" - DAB = "33" - UNIVERSAL_PORT = "40" - LINE = "41" - LINE2 = "42" - OPTICAL = "44" - COAXIAL = "45" - HDMI_5 = "55" - HDMI_6 = "56" - HDMI_7 = "57" - MAIN_SOURCE = "80" - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _INPUT_SOURCE_MEANINGS - - -_LISTENING_MODE_MEANINGS = { - "00": "STEREO", - "01": "DIRECT", - "02": "SURROUND", - "03": "FILM ··· GAME RPG ··· ADVANCED GAME", - "04": "THX", - "05": "ACTION ··· GAME ACTION", - "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", - "07": "MONO MOVIE", - "08": "ORCHESTRA ··· CLASSICAL", - "09": "UNPLUGGED", - "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", - "0B": "TV LOGIC ··· DRAMA", - "0C": "ALL CH STEREO ··· EXTENDED STEREO", - "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", - "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", - "0F": "MONO", - "11": "PURE AUDIO ··· PURE DIRECT", - "12": "MULTIPLEX", - "13": "FULL MONO ··· MONO MUSIC", - "14": "DOLBY VIRTUAL/SURROUND ENHANCER", - "15": "DTS SURROUND SENSATION", - "16": "AUDYSSEY DSX", - "17": "DTS VIRTUAL:X", - "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", - "23": "STAGE (JAPAN GENRE CONTROL)", - "25": "ACTION (JAPAN GENRE CONTROL)", - "26": "MUSIC (JAPAN GENRE CONTROL)", - "2E": "SPORTS (JAPAN GENRE CONTROL)", - "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", - "41": "DOLBY EX/DTS ES", - "42": "THX CINEMA", - "43": "THX SURROUND EX", - "44": "THX MUSIC", - "45": "THX GAMES", - "50": "THX U(2)/S(2)/I/S CINEMA", - "51": "THX U(2)/S(2)/I/S MUSIC", - "52": "THX U(2)/S(2)/I/S GAMES", - "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", - "81": "PLII/PLIIx MUSIC", - "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", - "83": "NEO:6/NEO:X MUSIC", - "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", - "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", - "86": "PLII/PLIIx GAME", - "87": "NEURAL SURR", - "88": "NEURAL THX/NEURAL SURROUND", - "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", - "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", - "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", - "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", - "8D": "NEURAL THX CINEMA", - "8E": "NEURAL THX MUSIC", - "8F": "NEURAL THX GAMES", - "90": "PLIIz HEIGHT", - "91": "NEO:6 CINEMA DTS SURROUND SENSATION", - "92": "NEO:6 MUSIC DTS SURROUND SENSATION", - "93": "NEURAL DIGITAL MUSIC", - "94": "PLIIz HEIGHT + THX CINEMA", - "95": "PLIIz HEIGHT + THX MUSIC", - "96": "PLIIz HEIGHT + THX GAMES", - "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", - "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", - "99": "PLIIz HEIGHT + THX U2/S2 GAMES", - "9A": "NEO:X GAME", - "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", - "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", - "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", - "A3": "NEO:6 CINEMA + AUDYSSEY DSX", - "A4": "NEO:6 MUSIC + AUDYSSEY DSX", - "A5": "NEURAL SURROUND + AUDYSSEY DSX", - "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", - "A7": "DOLBY EX + AUDYSSEY DSX", - "FF": "AUTO SURROUND", +LEGACY_HDMI_OUTPUT_MAPPING = { + HDMIOutput.ANALOG: "no,analog", + HDMIOutput.MAIN: "yes,out", + HDMIOutput.SUB: "out-sub,sub,hdbaset", + HDMIOutput.BOTH: "both,sub", + HDMIOutput.BOTH_MAIN: "both", + HDMIOutput.BOTH_SUB: "both", } - -class ListeningMode(EnumWithMeaning): - """Receiver listening mode.""" - - _ignore_ = "ListeningMode _k _v _meaning" - - ListeningMode = vars() - for _k in _LISTENING_MODE_MEANINGS: - ListeningMode["I" + _k] = _k - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _LISTENING_MODE_MEANINGS - - -ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} - -PYEISCP_COMMANDS = pyeiscp.commands.COMMANDS +LEGACY_REV_HDMI_OUTPUT_MAPPING = { + "analog": HDMIOutput.ANALOG, + "both": HDMIOutput.BOTH_SUB, + "hdbaset": HDMIOutput.SUB, + "no": HDMIOutput.ANALOG, + "out": HDMIOutput.MAIN, + "out-sub": HDMIOutput.SUB, + "sub": HDMIOutput.BOTH, + "yes": HDMIOutput.MAIN, +} diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 6f37fb61b44..6102f8f2495 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -3,11 +3,13 @@ "name": "Onkyo", "codeowners": ["@arturpragacz", "@eclair4151"], "config_flow": true, + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/onkyo", "integration_type": "device", "iot_class": "local_push", - "loggers": ["pyeiscp"], - "requirements": ["pyeiscp==0.0.7"], + "loggers": ["aioonkyo"], + "quality_scale": "bronze", + "requirements": ["aioonkyo==0.3.0"], "ssdp": [ { "manufacturer": "ONKYO", diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index aed7c51af80..05374bfe6cf 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -1,12 +1,12 @@ -"""Support for Onkyo Receivers.""" +"""Media player platform.""" from __future__ import annotations import asyncio -from enum import Enum -from functools import cache import logging -from typing import Any, Literal +from typing import Any + +from aioonkyo import Code, Kind, Status, Zone, command, query, status from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -14,23 +14,25 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OnkyoConfigEntry from .const import ( DOMAIN, + LEGACY_HDMI_OUTPUT_MAPPING, + LEGACY_REV_HDMI_OUTPUT_MAPPING, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, - PYEISCP_COMMANDS, ZONES, InputSource, ListeningMode, VolumeResolution, ) -from .receiver import Receiver +from .receiver import ReceiverManager from .services import DATA_MP_ENTITIES +from .util import get_meaning _LOGGER = logging.getLogger(__name__) @@ -86,64 +88,6 @@ VIDEO_INFORMATION_MAPPING = [ "input_hdr", ] -type LibValue = str | tuple[str, ...] - - -def _get_single_lib_value(value: LibValue) -> str: - if isinstance(value, str): - return value - return value[-1] - - -def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: - result: dict[T, LibValue] = {} - for k, v in cmds["values"].items(): - try: - key = cls(k) - except ValueError: - continue - result[key] = v["name"] - - return result - - -@cache -def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["SLI"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["SLZ"] - case "zone3": - cmds = PYEISCP_COMMANDS["zone3"]["SL3"] - case "zone4": - cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - - return _get_lib_mapping(cmds, InputSource) - - -@cache -def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: - return {value: key for key, value in _input_source_lib_mappings(zone).items()} - - -@cache -def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["LMD"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] - case _: - return {} - - return _get_lib_mapping(cmds, ListeningMode) - - -@cache -def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: - return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} - async def async_setup_entry( hass: HomeAssistant, @@ -153,10 +97,10 @@ async def async_setup_entry( """Set up MediaPlayer for config entry.""" data = entry.runtime_data - receiver = data.receiver + manager = data.manager all_entities = hass.data[DATA_MP_ENTITIES] - entities: dict[str, OnkyoMediaPlayer] = {} + entities: dict[Zone, OnkyoMediaPlayer] = {} all_entities[entry.entry_id] = entities volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] @@ -164,29 +108,33 @@ async def async_setup_entry( sources = data.sources sound_modes = data.sound_modes - def connect_callback(receiver: Receiver) -> None: - if not receiver.first_connect: + async def connect_callback(reconnect: bool) -> None: + if reconnect: for entity in entities.values(): if entity.enabled: - entity.backfill_state() + await entity.backfill_state() + + async def update_callback(message: Status) -> None: + if isinstance(message, status.Raw): + return + + zone = message.zone - def update_callback(receiver: Receiver, message: tuple[str, str, Any]) -> None: - zone, _, value = message entity = entities.get(zone) if entity is not None: if entity.enabled: entity.process_update(message) - elif zone in ZONES and value != "N/A": - # When we receive the status for a zone, and the value is not "N/A", - # then zone is available on the receiver, so we create the entity for it. + elif not isinstance(message, status.NotAvailable): + # When we receive a valid status for a zone, then that zone is available on the receiver, + # so we create the entity for it. _LOGGER.debug( "Discovered %s on %s (%s)", ZONES[zone], - receiver.model_name, - receiver.host, + manager.info.model_name, + manager.info.host, ) zone_entity = OnkyoMediaPlayer( - receiver, + manager, zone, volume_resolution=volume_resolution, max_volume=max_volume, @@ -196,25 +144,28 @@ async def async_setup_entry( entities[zone] = zone_entity async_add_entities([zone_entity]) - receiver.callbacks.connect.append(connect_callback) - receiver.callbacks.update.append(update_callback) + manager.callbacks.connect.append(connect_callback) + manager.callbacks.update.append(update_callback) class OnkyoMediaPlayer(MediaPlayerEntity): - """Representation of an Onkyo Receiver Media Player (one per each zone).""" + """Onkyo Receiver Media Player (one per each zone).""" _attr_should_poll = False + _attr_has_entity_name = True _supports_volume: bool = False - _supports_sound_mode: bool = False + # None means no technical possibility of support + _supports_sound_mode: bool | None = None _supports_audio_info: bool = False _supports_video_info: bool = False - _query_timer: asyncio.TimerHandle | None = None + + _query_task: asyncio.Task | None = None def __init__( self, - receiver: Receiver, - zone: str, + manager: ReceiverManager, + zone: Zone, *, volume_resolution: VolumeResolution, max_volume: float, @@ -222,80 +173,88 @@ class OnkyoMediaPlayer(MediaPlayerEntity): sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" - self._receiver = receiver - name = receiver.model_name - identifier = receiver.identifier - self._attr_name = f"{name}{' ' + ZONES[zone] if zone != 'main' else ''}" - self._attr_unique_id = f"{identifier}_{zone}" - + self._manager = manager self._zone = zone + name = manager.info.model_name + identifier = manager.info.identifier + self._attr_name = f"{name}{' ' + ZONES[zone] if zone != Zone.MAIN else ''}" + self._attr_unique_id = f"{identifier}_{zone.value}" + self._volume_resolution = volume_resolution self._max_volume = max_volume - self._options_sources = sources - self._source_lib_mapping = _input_source_lib_mappings(zone) - self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) + zone_sources = InputSource.for_zone(zone) self._source_mapping = { - key: value - for key, value in sources.items() - if key in self._source_lib_mapping + key: value for key, value in sources.items() if key in zone_sources } self._rev_source_mapping = { value: key for key, value in self._source_mapping.items() } - self._options_sound_modes = sound_modes - self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) - self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + zone_sound_modes = ListeningMode.for_zone(zone) self._sound_mode_mapping = { - key: value - for key, value in sound_modes.items() - if key in self._sound_mode_lib_mapping + key: value for key, value in sound_modes.items() if key in zone_sound_modes } self._rev_sound_mode_mapping = { value: key for key, value in self._sound_mode_mapping.items() } + self._hdmi_output_mapping = LEGACY_HDMI_OUTPUT_MAPPING + self._rev_hdmi_output_mapping = LEGACY_REV_HDMI_OUTPUT_MAPPING + self._attr_source_list = list(self._rev_source_mapping) self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) self._attr_supported_features = SUPPORTED_FEATURES_BASE - if zone == "main": + if zone == Zone.MAIN: self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME self._supports_volume = True self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._supports_sound_mode = True + elif Code.get_from_kind_zone(Kind.LISTENING_MODE, zone) is not None: + # To be detected later: + self._supports_sound_mode = False self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" - self.backfill_state() + await self.backfill_state() async def async_will_remove_from_hass(self) -> None: """Cancel the query timer when the entity is removed.""" - if self._query_timer: - self._query_timer.cancel() - self._query_timer = None + if self._query_task: + self._query_task.cancel() + self._query_task = None - @callback - def _update_receiver(self, propname: str, value: Any) -> None: - """Update a property in the receiver.""" - self._receiver.conn.update_property(self._zone, propname, value) + async def backfill_state(self) -> None: + """Get the receiver to send all the info we care about. - @callback - def _query_receiver(self, propname: str) -> None: - """Cause the receiver to send an update about a property.""" - self._receiver.conn.query_property(self._zone, propname) + Usually run only on connect, as we can otherwise rely on the + receiver to keep us informed of changes. + """ + await self._manager.write(query.Power(self._zone)) + await self._manager.write(query.Volume(self._zone)) + await self._manager.write(query.Muting(self._zone)) + await self._manager.write(query.InputSource(self._zone)) + await self._manager.write(query.TunerPreset(self._zone)) + if self._supports_sound_mode is not None: + await self._manager.write(query.ListeningMode(self._zone)) + if self._zone == Zone.MAIN: + await self._manager.write(query.HDMIOutput()) + await self._manager.write(query.AudioInformation()) + await self._manager.write(query.VideoInformation()) async def async_turn_on(self) -> None: """Turn the media player on.""" - self._update_receiver("power", "on") + message = command.Power(self._zone, command.Power.Param.ON) + await self._manager.write(message) async def async_turn_off(self) -> None: """Turn the media player off.""" - self._update_receiver("power", "standby") + message = command.Power(self._zone, command.Power.Param.STANDBY) + await self._manager.write(message) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1. @@ -307,28 +266,30 @@ class OnkyoMediaPlayer(MediaPlayerEntity): scale for the receiver. """ # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION - self._update_receiver( - "volume", round(volume * (self._max_volume / 100) * self._volume_resolution) - ) + value = round(volume * (self._max_volume / 100) * self._volume_resolution) + message = command.Volume(self._zone, value) + await self._manager.write(message) async def async_volume_up(self) -> None: """Increase volume by 1 step.""" - self._update_receiver("volume", "level-up") + message = command.Volume(self._zone, command.Volume.Param.UP) + await self._manager.write(message) async def async_volume_down(self) -> None: """Decrease volume by 1 step.""" - self._update_receiver("volume", "level-down") + message = command.Volume(self._zone, command.Volume.Param.DOWN) + await self._manager.write(message) async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - self._update_receiver( - "audio-muting" if self._zone == "main" else "muting", - "on" if mute else "off", + message = command.Muting( + self._zone, command.Muting.Param.ON if mute else command.Muting.Param.OFF ) + await self._manager.write(message) async def async_select_source(self, source: str) -> None: """Select input source.""" - if not self.source_list or source not in self.source_list: + if source not in self._rev_source_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_source", @@ -338,15 +299,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity): }, ) - source_lib = self._source_lib_mapping[self._rev_source_mapping[source]] - source_lib_single = _get_single_lib_value(source_lib) - self._update_receiver( - "input-selector" if self._zone == "main" else "selector", source_lib_single - ) + message = command.InputSource(self._zone, self._rev_source_mapping[source]) + await self._manager.write(message) async def async_select_sound_mode(self, sound_mode: str) -> None: """Select listening sound mode.""" - if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + if sound_mode not in self._rev_sound_mode_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_sound_mode", @@ -356,197 +314,138 @@ class OnkyoMediaPlayer(MediaPlayerEntity): }, ) - sound_mode_lib = self._sound_mode_lib_mapping[ - self._rev_sound_mode_mapping[sound_mode] - ] - sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) - self._update_receiver("listening-mode", sound_mode_lib_single) + message = command.ListeningMode( + self._zone, self._rev_sound_mode_mapping[sound_mode] + ) + await self._manager.write(message) async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" - self._update_receiver("hdmi-output-selector", hdmi_output) + message = command.HDMIOutput(self._rev_hdmi_output_mapping[hdmi_output]) + await self._manager.write(message) async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play radio station by preset number.""" - if self.source is not None: - source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: - self._update_receiver("preset", media_id) - - @callback - def backfill_state(self) -> None: - """Get the receiver to send all the info we care about. - - Usually run only on connect, as we can otherwise rely on the - receiver to keep us informed of changes. - """ - self._query_receiver("power") - self._query_receiver("volume") - self._query_receiver("preset") - if self._zone == "main": - self._query_receiver("hdmi-output-selector") - self._query_receiver("audio-muting") - self._query_receiver("input-selector") - self._query_receiver("listening-mode") - self._query_receiver("audio-information") - self._query_receiver("video-information") - else: - self._query_receiver("muting") - self._query_receiver("selector") - - @callback - def process_update(self, update: tuple[str, str, Any]) -> None: - """Store relevant updates so they can be queried later.""" - zone, command, value = update - if zone != self._zone: + if self.source is None: return - if command in ["system-power", "power"]: - if value == "on": + source = self._rev_source_mapping.get(self.source) + if media_type.lower() != "radio" or source not in PLAYABLE_SOURCES: + return + + message = command.TunerPreset(self._zone, int(media_id)) + await self._manager.write(message) + + def process_update(self, message: status.Known) -> None: + """Process update.""" + match message: + case status.Power(status.Power.Param.ON): self._attr_state = MediaPlayerState.ON - else: + case status.Power(status.Power.Param.STANDBY): self._attr_state = MediaPlayerState.OFF - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_PRESET, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) - elif command in ["volume", "master-volume"] and value != "N/A": - if not self._supports_volume: - self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME - self._supports_volume = True - # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) - volume_level: float = value / ( - self._volume_resolution * self._max_volume / 100 - ) - self._attr_volume_level = min(1, volume_level) - elif command in ["muting", "audio-muting"]: - self._attr_is_volume_muted = bool(value == "on") - elif command in ["selector", "input-selector"] and value != "N/A": - self._parse_source(value) - self._query_av_info_delayed() - elif command == "hdmi-output-selector": - self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ",".join(value) - elif command == "preset": - if self.source is not None and self.source.lower() == "radio": - self._attr_extra_state_attributes[ATTR_PRESET] = value - elif ATTR_PRESET in self._attr_extra_state_attributes: - del self._attr_extra_state_attributes[ATTR_PRESET] - elif command == "listening-mode" and value != "N/A": - if not self._supports_sound_mode: - self._attr_supported_features |= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE + + case status.Volume(volume): + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) + volume_level: float = volume / ( + self._volume_resolution * self._max_volume / 100 ) - self._supports_sound_mode = True - self._parse_sound_mode(value) - self._query_av_info_delayed() - elif command == "audio-information": - self._supports_audio_info = True - self._parse_audio_information(value) - elif command == "video-information": - self._supports_video_info = True - self._parse_video_information(value) - elif command == "fl-display-information": - self._query_av_info_delayed() + self._attr_volume_level = min(1, volume_level) + + case status.Muting(muting): + self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON) + + case status.InputSource(source): + if source in self._source_mapping: + self._attr_source = self._source_mapping[source] + else: + source_meaning = get_meaning(source) + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning + + self._query_av_info_delayed() + + case status.ListeningMode(sound_mode): + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + else: + sound_mode_meaning = get_meaning(sound_mode) + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning + + self._query_av_info_delayed() + + case status.HDMIOutput(hdmi_output): + self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ( + self._hdmi_output_mapping[hdmi_output] + ) + self._query_av_info_delayed() + + case status.TunerPreset(preset): + self._attr_extra_state_attributes[ATTR_PRESET] = preset + + case status.AudioInformation(): + self._supports_audio_info = True + audio_information = {} + for item in AUDIO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + audio_information[item] = item_value + self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = ( + audio_information + ) + + case status.VideoInformation(): + self._supports_video_info = True + video_information = {} + for item in VIDEO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + video_information[item] = item_value + self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = ( + video_information + ) + + case status.FLDisplay(): + self._query_av_info_delayed() + + case status.NotAvailable(Kind.AUDIO_INFORMATION): + # Not available right now, but still supported + self._supports_audio_info = True + + case status.NotAvailable(Kind.VIDEO_INFORMATION): + # Not available right now, but still supported + self._supports_video_info = True self.async_write_ha_state() - @callback - def _parse_source(self, source_lib: LibValue) -> None: - source = self._rev_source_lib_mapping[source_lib] - if source in self._source_mapping: - self._attr_source = self._source_mapping[source] - return - - source_meaning = source.value_meaning - - if source not in self._options_sources: - _LOGGER.warning( - 'Input source "%s" for entity: %s is not in the list. Check integration options', - source_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) - - self._attr_source = source_meaning - - @callback - def _parse_sound_mode(self, mode_lib: LibValue) -> None: - sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] - if sound_mode in self._sound_mode_mapping: - self._attr_sound_mode = self._sound_mode_mapping[sound_mode] - return - - sound_mode_meaning = sound_mode.value_meaning - - if sound_mode not in self._options_sound_modes: - _LOGGER.warning( - 'Listening mode "%s" for entity: %s is not in the list. Check integration options', - sound_mode_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Listening mode "%s" is invalid for entity: %s', - sound_mode_meaning, - self.entity_id, - ) - - self._attr_sound_mode = sound_mode_meaning - - @callback - def _parse_audio_information( - self, audio_information: tuple[str] | Literal["N/A"] - ) -> None: - # If audio information is not available, N/A is returned, - # so only update the audio information, when it is not N/A. - if audio_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - return - - self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = { - name: value - for name, value in zip( - AUDIO_INFORMATION_MAPPING, audio_information, strict=False - ) - if len(value) > 0 - } - - @callback - def _parse_video_information( - self, video_information: tuple[str] | Literal["N/A"] - ) -> None: - # If video information is not available, N/A is returned, - # so only update the video information, when it is not N/A. - if video_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - return - - self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = { - name: value - for name, value in zip( - VIDEO_INFORMATION_MAPPING, video_information, strict=False - ) - if len(value) > 0 - } - def _query_av_info_delayed(self) -> None: - if self._zone == "main" and not self._query_timer: + if self._zone == Zone.MAIN and not self._query_task: - @callback - def _query_av_info() -> None: + async def _query_av_info() -> None: + await asyncio.sleep(AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME) if self._supports_audio_info: - self._query_receiver("audio-information") + await self._manager.write(query.AudioInformation()) if self._supports_video_info: - self._query_receiver("video-information") - self._query_timer = None + await self._manager.write(query.VideoInformation()) + self._query_task = None - self._query_timer = self.hass.loop.call_later( - AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME, _query_av_info - ) + self._query_task = asyncio.create_task(_query_av_info()) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 4b9fbe7c019..758055a974c 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: - status: todo - comment: | - Coverage is 100%, but the tests need to be improved. + config-flow-test-coverage: done dependency-transparency: done docs-actions: done docs-high-level-description: done @@ -22,7 +19,7 @@ rules: comment: | Currently we store created entities in hass.data. That should be removed in the future. entity-unique-id: done - has-entity-name: todo + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -39,9 +36,9 @@ rules: parallel-updates: todo reauthentication-flow: status: exempt - comment: | - This integration does not require authentication. - test-coverage: todo + comment: This integration does not require authentication. + test-coverage: done + # Gold devices: todo diagnostics: todo @@ -77,7 +74,4 @@ rules: status: exempt comment: | This integration is not making any HTTP requests. - strict-typing: - status: todo - comment: | - The library is not fully typed yet. + strict-typing: done diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py index cc6cbbc95fb..e4fe8bc6630 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -3,149 +3,149 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable import contextlib from dataclasses import dataclass, field import logging -from typing import Any +from typing import TYPE_CHECKING -import pyeiscp +import aioonkyo +from aioonkyo import Instruction, Receiver, ReceiverInfo, Status, connect, query + +from homeassistant.components import network +from homeassistant.core import HomeAssistant from .const import DEVICE_DISCOVERY_TIMEOUT, DEVICE_INTERVIEW_TIMEOUT, ZONES +if TYPE_CHECKING: + from . import OnkyoConfigEntry + _LOGGER = logging.getLogger(__name__) @dataclass class Callbacks: - """Onkyo Receiver Callbacks.""" + """Receiver callbacks.""" - connect: list[Callable[[Receiver], None]] = field(default_factory=list) - update: list[Callable[[Receiver, tuple[str, str, Any]], None]] = field( - default_factory=list - ) + connect: list[Callable[[bool], Awaitable[None]]] = field(default_factory=list) + update: list[Callable[[Status], Awaitable[None]]] = field(default_factory=list) + + def clear(self) -> None: + """Clear all callbacks.""" + self.connect.clear() + self.update.clear() -@dataclass -class Receiver: - """Onkyo receiver.""" +class ReceiverManager: + """Receiver manager.""" - conn: pyeiscp.Connection - model_name: str - identifier: str - host: str - first_connect: bool = True - callbacks: Callbacks = field(default_factory=Callbacks) + hass: HomeAssistant + entry: OnkyoConfigEntry + info: ReceiverInfo + receiver: Receiver | None = None + callbacks: Callbacks - @classmethod - async def async_create(cls, info: ReceiverInfo) -> Receiver: - """Set up Onkyo Receiver.""" + _started: asyncio.Event - receiver: Receiver | None = None + def __init__( + self, hass: HomeAssistant, entry: OnkyoConfigEntry, info: ReceiverInfo + ) -> None: + """Init receiver manager.""" + self.hass = hass + self.entry = entry + self.info = info + self.callbacks = Callbacks() + self._started = asyncio.Event() - def on_connect(_origin: str) -> None: - assert receiver is not None - receiver.on_connect() + async def start(self) -> Awaitable[None] | None: + """Start the receiver manager run. - def on_update(message: tuple[str, str, Any], _origin: str) -> None: - assert receiver is not None - receiver.on_update(message) - - _LOGGER.debug("Creating receiver: %s (%s)", info.model_name, info.host) - - connection = await pyeiscp.Connection.create( - host=info.host, - port=info.port, - connect_callback=on_connect, - update_callback=on_update, - auto_connect=False, + Returns `None`, if everything went fine. + Returns an awaitable with exception set, if something went wrong. + """ + manager_task = self.entry.async_create_background_task( + self.hass, self._run(), "run_connection" ) - - return ( - receiver := cls( - conn=connection, - model_name=info.model_name, - identifier=info.identifier, - host=info.host, - ) + wait_for_started_task = asyncio.create_task(self._started.wait()) + done, _ = await asyncio.wait( + (manager_task, wait_for_started_task), return_when=asyncio.FIRST_COMPLETED ) + if manager_task in done: + # Something went wrong, so let's return the manager task, + # so that it can be awaited to error out + return manager_task - def on_connect(self) -> None: + return None + + async def _run(self) -> None: + """Run the connection to the receiver.""" + reconnect = False + while True: + try: + async with connect(self.info, retry=reconnect) as self.receiver: + if not reconnect: + self._started.set() + else: + _LOGGER.info("Reconnected: %s", self.info) + + await self.on_connect(reconnect=reconnect) + + while message := await self.receiver.read(): + await self.on_update(message) + + reconnect = True + + finally: + _LOGGER.info("Disconnected: %s", self.info) + + async def on_connect(self, reconnect: bool) -> None: """Receiver (re)connected.""" - _LOGGER.debug("Receiver (re)connected: %s (%s)", self.model_name, self.host) # Discover what zones are available for the receiver by querying the power. # If we get a response for the specific zone, it means it is available. for zone in ZONES: - self.conn.query_property(zone, "power") + await self.write(query.Power(zone)) for callback in self.callbacks.connect: - callback(self) + await callback(reconnect) - self.first_connect = False - - def on_update(self, message: tuple[str, str, Any]) -> None: + async def on_update(self, message: Status) -> None: """Process new message from the receiver.""" - _LOGGER.debug("Received update callback from %s: %s", self.model_name, message) for callback in self.callbacks.update: - callback(self, message) + await callback(message) + async def write(self, message: Instruction) -> None: + """Write message to the receiver.""" + assert self.receiver is not None + await self.receiver.write(message) -@dataclass -class ReceiverInfo: - """Onkyo receiver information.""" - - host: str - port: int - model_name: str - identifier: str + def start_unloading(self) -> None: + """Start unloading.""" + self.callbacks.clear() async def async_interview(host: str) -> ReceiverInfo | None: - """Interview Onkyo Receiver.""" - _LOGGER.debug("Interviewing receiver: %s", host) - - receiver_info: ReceiverInfo | None = None - - event = asyncio.Event() - - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver interviewed, connection not yet active.""" - nonlocal receiver_info - if receiver_info is None: - info = ReceiverInfo(host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver interviewed: %s (%s)", info.model_name, info.host) - receiver_info = info - event.set() - - timeout = DEVICE_INTERVIEW_TIMEOUT - - await pyeiscp.Connection.discover( - host=host, discovery_callback=_callback, timeout=timeout - ) - + """Interview the receiver.""" + info: ReceiverInfo | None = None with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout) - - return receiver_info + async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT): + info = await aioonkyo.interview(host) + return info -async def async_discover() -> Iterable[ReceiverInfo]: - """Discover Onkyo Receivers.""" - _LOGGER.debug("Discovering receivers") +async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]: + """Discover receivers.""" + all_infos: dict[str, ReceiverInfo] = {} - receiver_infos: list[ReceiverInfo] = [] + async def collect_infos(address: str) -> None: + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(DEVICE_DISCOVERY_TIMEOUT): + async for info in aioonkyo.discover(address): + all_infos.setdefault(info.identifier, info) - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver discovered, connection not yet active.""" - info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver discovered: %s (%s)", info.model_name, info.host) - receiver_infos.append(info) + broadcast_addrs = await network.async_get_ipv4_broadcast_addresses(hass) + tasks = [collect_infos(str(address)) for address in broadcast_addrs] - timeout = DEVICE_DISCOVERY_TIMEOUT + await asyncio.gather(*tasks) - await pyeiscp.Connection.discover(discovery_callback=_callback, timeout=timeout) - - await asyncio.sleep(timeout) - - return receiver_infos + return all_infos.values() diff --git a/homeassistant/components/onkyo/services.py b/homeassistant/components/onkyo/services.py index 26a22523a0e..cfd246d9af7 100644 --- a/homeassistant/components/onkyo/services.py +++ b/homeassistant/components/onkyo/services.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from aioonkyo import Zone import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN @@ -12,29 +13,18 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DOMAIN, LEGACY_REV_HDMI_OUTPUT_MAPPING if TYPE_CHECKING: from .media_player import OnkyoMediaPlayer -DATA_MP_ENTITIES: HassKey[dict[str, dict[str, OnkyoMediaPlayer]]] = HassKey(DOMAIN) +DATA_MP_ENTITIES: HassKey[dict[str, dict[Zone, OnkyoMediaPlayer]]] = HassKey(DOMAIN) ATTR_HDMI_OUTPUT = "hdmi_output" -ACCEPTED_VALUES = [ - "no", - "analog", - "yes", - "out", - "out-sub", - "sub", - "hdbaset", - "both", - "up", -] ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_HDMI_OUTPUT): vol.In(ACCEPTED_VALUES), + vol.Required(ATTR_HDMI_OUTPUT): vol.In(LEGACY_REV_HDMI_OUTPUT_MAPPING), } ) SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" diff --git a/homeassistant/components/onkyo/util.py b/homeassistant/components/onkyo/util.py new file mode 100644 index 00000000000..bd2cc8a4c7b --- /dev/null +++ b/homeassistant/components/onkyo/util.py @@ -0,0 +1,8 @@ +"""Utils for Onkyo.""" + +from .const import InputSource, ListeningMode + + +def get_meaning(param: InputSource | ListeningMode) -> str: + """Get param meaning.""" + return " ··· ".join(param.meanings) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 057993be181..83dc238d2c4 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,7 +1,7 @@ """The ONVIF integration.""" import asyncio -from contextlib import suppress +from contextlib import AsyncExitStack, suppress from http import HTTPStatus import logging @@ -45,50 +45,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device = ONVIFDevice(hass, entry) - try: - await device.async_setup() - if not entry.data.get(CONF_SNAPSHOT_AUTH): - await async_populate_snapshot_auth(hass, device, entry) - except (TimeoutError, aiohttp.ClientError) as err: - await device.device.close() - raise ConfigEntryNotReady( - f"Could not connect to camera {device.device.host}:{device.device.port}: {err}" - ) from err - except Fault as err: - await device.device.close() - if is_auth_error(err): - raise ConfigEntryAuthFailed( - f"Auth Failed: {stringify_onvif_error(err)}" - ) from err - raise ConfigEntryNotReady( - f"Could not connect to camera: {stringify_onvif_error(err)}" - ) from err - except ONVIFError as err: - await device.device.close() - raise ConfigEntryNotReady( - f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}" - ) from err - except TransportError as err: - await device.device.close() - stringified_onvif_error = stringify_onvif_error(err) - if err.status_code in ( - HTTPStatus.UNAUTHORIZED.value, - HTTPStatus.FORBIDDEN.value, - ): - raise ConfigEntryAuthFailed( - f"Auth Failed: {stringified_onvif_error}" - ) from err - raise ConfigEntryNotReady( - f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}" - ) from err - except asyncio.CancelledError as err: - # After https://github.com/agronholm/anyio/issues/374 is resolved - # this may be able to be removed - await device.device.close() - raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err + async with AsyncExitStack() as stack: + # Register cleanup callback for device + @stack.push_async_callback + async def _cleanup(): + await _async_stop_device(hass, device) - if not device.available: - raise ConfigEntryNotReady + try: + await device.async_setup() + if not entry.data.get(CONF_SNAPSHOT_AUTH): + await async_populate_snapshot_auth(hass, device, entry) + except (TimeoutError, aiohttp.ClientError) as err: + raise ConfigEntryNotReady( + f"Could not connect to camera {device.device.host}:{device.device.port}: {err}" + ) from err + except Fault as err: + if is_auth_error(err): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringify_onvif_error(err)}" + ) from err + raise ConfigEntryNotReady( + f"Could not connect to camera: {stringify_onvif_error(err)}" + ) from err + except ONVIFError as err: + raise ConfigEntryNotReady( + f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}" + ) from err + except TransportError as err: + stringified_onvif_error = stringify_onvif_error(err) + if err.status_code in ( + HTTPStatus.UNAUTHORIZED.value, + HTTPStatus.FORBIDDEN.value, + ): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringified_onvif_error}" + ) from err + raise ConfigEntryNotReady( + f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}" + ) from err + except asyncio.CancelledError as err: + # After https://github.com/agronholm/anyio/issues/374 is resolved + # this may be able to be removed + raise ConfigEntryNotReady( + f"Setup was unexpectedly canceled: {err}" + ) from err + + if not device.available: + raise ConfigEntryNotReady + + # If we get here, setup was successful - prevent cleanup + stack.pop_all() hass.data[DOMAIN][entry.unique_id] = device @@ -111,17 +117,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] - +async def _async_stop_device(hass: HomeAssistant, device: ONVIFDevice) -> None: + """Stop the ONVIF device.""" if device.capabilities.events and device.events.started: try: await device.events.async_stop() except (TimeoutError, ONVIFError, Fault, aiohttp.ClientError, TransportError): LOGGER.warning("Error while stopping events: %s", device.name) + await device.device.close() + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + await _async_stop_device(hass, device) return await hass.config_entries.async_unload_platforms(entry, device.platforms) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 63b7437be39..787040d5691 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==4.0.1", "WSDiscovery==2.1.2"] + "requirements": ["onvif-zeep-async==4.0.3", "WSDiscovery==2.1.2"] } diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py new file mode 100644 index 00000000000..9850f72f71d --- /dev/null +++ b/homeassistant/components/open_router/__init__.py @@ -0,0 +1,58 @@ +"""The OpenRouter integration.""" + +from __future__ import annotations + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client + +from .const import LOGGER + +PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION] + +type OpenRouterConfigEntry = ConfigEntry[AsyncOpenAI] + + +async def async_setup_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Set up OpenRouter from a config entry.""" + client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=entry.data[CONF_API_KEY], + http_client=get_async_client(hass), + ) + + # Cache current platform data which gets added to each request (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + + try: + async for _ in client.with_options(timeout=10.0).models.list(): + break + except AuthenticationError as err: + LOGGER.error("Invalid API key: %s", err) + raise ConfigEntryError("Invalid API key") from err + except OpenAIError as err: + raise ConfigEntryNotReady(err) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: OpenRouterConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Unload OpenRouter.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/open_router/ai_task.py b/homeassistant/components/open_router/ai_task.py new file mode 100644 index 00000000000..fa5d8d0f68e --- /dev/null +++ b/homeassistant/components/open_router/ai_task.py @@ -0,0 +1,75 @@ +"""AI Task integration for OpenRouter.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads + +from . import OpenRouterConfigEntry +from .entity import OpenRouterEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenRouterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OpenRouterAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OpenRouterAITaskEntity( + ai_task.AITaskEntity, + OpenRouterEntity, +): + """OpenRouter AI Task entity.""" + + _attr_name = None + _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.name, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + raise HomeAssistantError( + "Error with OpenRouter structured response" + ) from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py new file mode 100644 index 00000000000..2afe2129a4c --- /dev/null +++ b/homeassistant/components/open_router/config_flow.py @@ -0,0 +1,200 @@ +"""Config flow for OpenRouter integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from python_open_router import ( + Model, + OpenRouterClient, + OpenRouterError, + SupportedParameter, +) +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import callback +from homeassistant.helpers import llm +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS + +_LOGGER = logging.getLogger(__name__) + + +class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenRouter.""" + + VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return { + "conversation": ConversationFlowHandler, + "ai_task_data": AITaskDataFlowHandler, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + client = OpenRouterClient( + user_input[CONF_API_KEY], async_get_clientsession(self.hass) + ) + try: + await client.get_key_data() + except OpenRouterError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="OpenRouter", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class OpenRouterSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for OpenRouter.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.models: dict[str, Model] = {} + + async def _get_models(self) -> None: + """Fetch models from OpenRouter.""" + entry = self._get_entry() + client = OpenRouterClient( + entry.data[CONF_API_KEY], async_get_clientsession(self.hass) + ) + models = await client.get_models() + self.models = {model.id: model for model in models} + + +class ConversationFlowHandler(OpenRouterSubentryFlowHandler): + """Handle subentry flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + return self.async_create_entry( + title=self.models[user_input[CONF_MODEL]].name, data=user_input + ) + try: + await self._get_models() + except OpenRouterError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + options = [ + SelectOptionDict(value=model.id, label=model.name) + for model in self.models.values() + ] + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": RECOMMENDED_CONVERSATION_OPTIONS[ + CONF_PROMPT + ] + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) + + +class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler): + """Handle subentry flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + return self.async_create_entry( + title=self.models[user_input[CONF_MODEL]].name, data=user_input + ) + try: + await self._get_models() + except OpenRouterError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + options = [ + SelectOptionDict(value=model.id, label=model.name) + for model in self.models.values() + if SupportedParameter.STRUCTURED_OUTPUTS in model.supported_parameters + ] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + } + ), + ) diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py new file mode 100644 index 00000000000..7316d45c3e5 --- /dev/null +++ b/homeassistant/components/open_router/const.py @@ -0,0 +1,17 @@ +"""Constants for the OpenRouter integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.helpers import llm + +DOMAIN = "open_router" +LOGGER = logging.getLogger(__package__) + +CONF_RECOMMENDED = "recommended" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py new file mode 100644 index 00000000000..3c185ecd77c --- /dev/null +++ b/homeassistant/components/open_router/conversation.py @@ -0,0 +1,69 @@ +"""Conversation support for OpenRouter.""" + +from typing import Literal + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenRouterConfigEntry +from .const import DOMAIN +from .entity import OpenRouterEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenRouterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type != "conversation": + continue + async_add_entities( + [OpenRouterConversationEntity(config_entry, subentry)], + config_subentry_id=subentry_id, + ) + + +class OpenRouterConversationEntity(OpenRouterEntity, conversation.ConversationEntity): + """OpenRouter conversation agent.""" + + _attr_name = None + + def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process the user input and call the API.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py new file mode 100644 index 00000000000..aa74442f7f4 --- /dev/null +++ b/homeassistant/components/open_router/entity.py @@ -0,0 +1,250 @@ +"""Base entity for Open Router.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Callable +import json +from typing import TYPE_CHECKING, Any, Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema +from openai.types.shared_params.response_format_json_schema import JSONSchema +import voluptuous as vol +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OpenRouterConfigEntry +from .const import DOMAIN, LOGGER + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +def _adjust_schema(schema: dict[str, Any]) -> None: + """Adjust the schema to be compatible with OpenRouter API.""" + if schema["type"] == "object": + if "properties" not in schema: + return + + if "required" not in schema: + schema["required"] = [] + + # Ensure all properties are required + for prop, prop_info in schema["properties"].items(): + _adjust_schema(prop_info) + if prop not in schema["required"]: + prop_info["type"] = [prop_info["type"], "null"] + schema["required"].append(prop) + + elif schema["type"] == "array": + if "items" not in schema: + return + + _adjust_schema(schema["items"]) + + +def _format_structured_output( + name: str, schema: vol.Schema, llm_api: llm.APIInstance | None +) -> JSONSchema: + """Format the schema to be compatible with OpenRouter API.""" + result: JSONSchema = { + "name": name, + "strict": True, + } + result_schema = convert( + schema, + custom_serializer=( + llm_api.custom_serializer if llm_api else llm.selector_serializer + ), + ) + + _adjust_schema(result_schema) + + result["schema"] = result_schema + return result + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageFunctionToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenRouter message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if tool_call.type == "function" + ] + yield data + + +class OpenRouterEntity(Entity): + """Base entity for Open Router.""" + + _attr_has_entity_name = True + + def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + ) -> None: + """Generate an answer for the chat log.""" + + model_args = { + "model": self.model, + "user": chat_log.conversation_id, + "extra_headers": { + "X-Title": "Home Assistant", + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + }, + "extra_body": {"require_parameters": True}, + } + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + if structure: + if TYPE_CHECKING: + assert structure_name is not None + model_args["response_format"] = ResponseFormatJSONSchema( + type="json_schema", + json_schema=_format_structured_output( + structure_name, structure, chat_log.llm_api + ), + ) + + client = self.entry.runtime_data + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create(**model_args) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + result_message = result.choices[0].message + + model_args["messages"].extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json new file mode 100644 index 00000000000..4a406e06139 --- /dev/null +++ b/homeassistant/components/open_router/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "open_router", + "name": "OpenRouter", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/open_router", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==1.99.5", "python-open-router==0.3.1"] +} diff --git a/homeassistant/components/open_router/quality_scale.yaml b/homeassistant/components/open_router/quality_scale.yaml new file mode 100644 index 00000000000..9b71a29dc6b --- /dev/null +++ b/homeassistant/components/open_router/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless conversation entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json new file mode 100644 index 00000000000..43a27a91959 --- /dev/null +++ b/homeassistant/components/open_router/strings.json @@ -0,0 +1,64 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "An OpenRouter API key" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "config_subentries": { + "conversation": { + "step": { + "user": { + "description": "Configure the new conversation agent", + "data": { + "model": "Model", + "prompt": "[%key:common::config_flow::data::prompt%]", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" + }, + "data_description": { + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + } + } + }, + "initiate_flow": { + "user": "Add conversation agent" + }, + "entry_type": "Conversation agent", + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "ai_task_data": { + "step": { + "user": { + "data": { + "model": "[%key:component::open_router::config_subentries::conversation::step::user::data::model%]" + } + } + }, + "initiate_flow": { + "user": "Add AI task" + }, + "entry_type": "AI task", + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } + } +} diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 71effe83884..f50563b59ea 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,24 +2,21 @@ from __future__ import annotations -import base64 -from mimetypes import guess_file_type from pathlib import Path +from types import MappingProxyType import openai from openai.types.images_response import ImagesResponse from openai.types.responses import ( EasyInputMessageParam, Response, - ResponseInputFileParam, - ResponseInputImageParam, ResponseInputMessageContentListParam, ResponseInputParam, ResponseInputTextParam, ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import ( HomeAssistant, @@ -32,7 +29,12 @@ from homeassistant.exceptions import ( HomeAssistantError, ServiceValidationError, ) -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + selector, +) from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType @@ -44,35 +46,31 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_AI_TASK_NAME, + DEFAULT_NAME, DOMAIN, LOGGER, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, ) +from .entity import async_prepare_files_for_prompt SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_CONTENT = "generate_content" -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] -def encode_file(file_path: str) -> tuple[str, str]: - """Return base64 version of file contents.""" - mime_type, _ = guess_file_type(file_path) - if mime_type is None: - mime_type = "application/octet-stream" - with open(file_path, "rb") as image_file: - return (mime_type, base64.b64encode(image_file.read()).decode("utf-8")) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" + await async_migrate_integration(hass) async def render_image(call: ServiceCall) -> ServiceResponse: """Render an image with dall-e.""" @@ -118,75 +116,68 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: translation_placeholders={"config_entry": entry_id}, ) - model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Get first conversation subentry for options + conversation_subentry = next( + ( + sub + for sub in entry.subentries.values() + if sub.subentry_type == "conversation" + ), + None, + ) + if not conversation_subentry: + raise ServiceValidationError("No conversation configuration found") + + model: str = conversation_subentry.data.get( + CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL + ) client: openai.AsyncClient = entry.runtime_data content: ResponseInputMessageContentListParam = [ ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT]) ] - def append_files_to_content() -> None: - for filename in call.data[CONF_FILENAMES]: + if filenames := call.data.get(CONF_FILENAMES): + for filename in filenames: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( f"Cannot read `{filename}`, no access to path; " "`allowlist_external_dirs` may need to be adjusted in " "`configuration.yaml`" ) - if not Path(filename).exists(): - raise HomeAssistantError(f"`{filename}` does not exist") - mime_type, base64_file = encode_file(filename) - if "image/" in mime_type: - content.append( - ResponseInputImageParam( - type="input_image", - image_url=f"data:{mime_type};base64,{base64_file}", - detail="auto", - ) - ) - elif "application/pdf" in mime_type: - content.append( - ResponseInputFileParam( - type="input_file", - filename=filename, - file_data=f"data:{mime_type};base64,{base64_file}", - ) - ) - else: - raise HomeAssistantError( - "Only images and PDF are supported by the OpenAI API," - f"`{filename}` is not an image file or PDF" - ) - if CONF_FILENAMES in call.data: - await hass.async_add_executor_job(append_files_to_content) + content.extend( + await async_prepare_files_for_prompt( + hass, [Path(filename) for filename in filenames] + ) + ) messages: ResponseInputParam = [ EasyInputMessageParam(type="message", role="user", content=content) ] - try: - model_args = { - "model": model, - "input": messages, - "max_output_tokens": entry.options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": entry.options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - "user": call.context.user_id, - "store": False, + model_args = { + "model": model, + "input": messages, + "max_output_tokens": conversation_subentry.data.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": conversation_subentry.data.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "user": call.context.user_id, + "store": False, + } + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": conversation_subentry.data.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) } - if model.startswith("o"): - model_args["reasoning"] = { - "effort": entry.options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - + try: response: Response = await client.responses.create(**model_args) except openai.OpenAIError as err: @@ -263,9 +254,203 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo 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_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload OpenAI.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_API_KEY] not in api_keys_entries: + use_existing = True + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) + + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity_id = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + _add_ai_task_subentry(hass, entry) + hass.config_entries.async_update_entry( + entry, + title=DEFAULT_NAME, + options={}, + version=2, + minor_version=4, + ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + if entry.version == 2 and entry.minor_version == 2: + _add_ai_task_subentry(hass, entry) + hass.config_entries.async_update_entry(entry, minor_version=3) + + if entry.version == 2 and entry.minor_version == 3: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=4) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py new file mode 100644 index 00000000000..5fc700a73ad --- /dev/null +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -0,0 +1,80 @@ +"""AI Task integration for OpenAI.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +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.util.json import json_loads + +from .entity import OpenAIBaseLLMEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OpenAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OpenAITaskEntity( + ai_task.AITaskEntity, + OpenAIBaseLLMEntity, +): + """OpenAI AI Task entity.""" + + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.name, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + _LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError("Error with OpenAI structured response") from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 60d81bf6745..0b2fa75b5c0 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -13,17 +13,20 @@ from voluptuous_openapi import convert from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, CONF_LLM_HASS_API, + CONF_NAME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( @@ -39,12 +42,14 @@ from homeassistant.helpers.typing import VolDictType from .const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_VERBOSITY, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -52,17 +57,23 @@ from .const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CODE_INTERPRETER, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_VERBOSITY, RECOMMENDED_WEB_SEARCH, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_MODELS, - WEB_SEARCH_MODELS, + UNSUPPORTED_WEB_SEARCH_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -73,12 +84,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -94,7 +99,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" - VERSION = 1 + VERSION = 2 + MINOR_VERSION = 4 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -107,6 +113,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} + self._async_abort_entries_match(user_input) try: await validate_input(self.hass, user_input) except openai.APIConnectionError: @@ -120,32 +127,74 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="ChatGPT", data=user_input, - options=RECOMMENDED_OPTIONS, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, + ], ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return OpenAIOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return { + "conversation": OpenAISubentryFlowHandler, + "ai_task_data": OpenAISubentryFlowHandler, + } -class OpenAIOptionsFlow(OptionsFlow): - """OpenAI config flow options handler.""" +class OpenAISubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing OpenAI subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.options = config_entry.options.copy() + last_rendered_recommended = False + 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.""" + if self._subentry_type == "ai_task_data": + self.options = RECOMMENDED_AI_TASK_OPTIONS.copy() + else: + self.options = RECOMMENDED_CONVERSATION_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 - ) -> ConfigFlowResult: + ) -> 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 hass_apis: list[SelectOptionDict] = [ @@ -160,25 +209,51 @@ class OpenAIOptionsFlow(OptionsFlow): ): options[CONF_LLM_HASS_API] = [suggested_llm_apis] - step_schema: VolDictType = { - vol.Optional( - CONF_PROMPT, - description={"suggested_value": llm.DEFAULT_INSTRUCTIONS_PROMPT}, - ): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API): SelectSelector( - SelectSelectorConfig(options=hass_apis, multiple=True) - ), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } + step_schema: VolDictType = {} + + if self._is_new: + if self._subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME + step_schema[vol.Required(CONF_NAME, default=default_name)] = str + + if self._subentry_type == "conversation": + step_schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional(CONF_LLM_HASS_API): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ) + + step_schema[ + vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)) + ] = bool if user_input is not None: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) if user_input[CONF_RECOMMENDED]: - return self.async_create_entry(title="", data=user_input) + if self._is_new: + return self.async_create_entry( + title=user_input.pop(CONF_NAME), + data=user_input, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + ) options.update(user_input) if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input: @@ -194,7 +269,7 @@ class OpenAIOptionsFlow(OptionsFlow): async def async_step_advanced( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Manage advanced options.""" options = self.options errors: dict[str, str] = {} @@ -236,16 +311,21 @@ class OpenAIOptionsFlow(OptionsFlow): async def async_step_model( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Manage model-specific options.""" options = self.options errors: dict[str, str] = {} - step_schema: VolDictType = {} + step_schema: VolDictType = { + vol.Optional( + CONF_CODE_INTERPRETER, + default=RECOMMENDED_CODE_INTERPRETER, + ): bool, + } model = options[CONF_CHAT_MODEL] - if model.startswith("o"): + if model.startswith(("o", "gpt-5")): step_schema.update( { vol.Optional( @@ -253,7 +333,9 @@ class OpenAIOptionsFlow(OptionsFlow): default=RECOMMENDED_REASONING_EFFORT, ): SelectSelector( SelectSelectorConfig( - options=["low", "medium", "high"], + options=["low", "medium", "high"] + if model.startswith("o") + else ["minimal", "low", "medium", "high"], translation_key=CONF_REASONING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) @@ -263,7 +345,27 @@ class OpenAIOptionsFlow(OptionsFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) - if model.startswith(tuple(WEB_SEARCH_MODELS)): + if model.startswith("gpt-5"): + step_schema.update( + { + vol.Optional( + CONF_VERBOSITY, + default=RECOMMENDED_VERBOSITY, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_VERBOSITY, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + elif CONF_VERBOSITY in options: + options.pop(CONF_VERBOSITY) + + if self._subentry_type == "conversation" and not model.startswith( + tuple(UNSUPPORTED_WEB_SEARCH_MODELS) + ): step_schema.update( { vol.Optional( @@ -302,9 +404,6 @@ class OpenAIOptionsFlow(OptionsFlow): ) } - if not step_schema: - return self.async_create_entry(title="", data=options) - if user_input is not None: if user_input.get(CONF_WEB_SEARCH): if user_input.get(CONF_WEB_SEARCH_USER_LOCATION): @@ -316,7 +415,16 @@ class OpenAIOptionsFlow(OptionsFlow): options.pop(CONF_WEB_SEARCH_TIMEZONE, None) options.update(user_input) - return self.async_create_entry(title="", data=options) + 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="model", @@ -332,7 +440,7 @@ class OpenAIOptionsFlow(OptionsFlow): zone_home = self.hass.states.get(ENTITY_ID_HOME) if zone_home is not None: client = openai.AsyncOpenAI( - api_key=self.config_entry.data[CONF_API_KEY], + api_key=self._get_entry().data[CONF_API_KEY], http_client=get_async_client(self.hass), ) location_schema = vol.Schema( diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index f022b4840eb..2fd18913207 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -2,18 +2,26 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) +DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" +DEFAULT_AI_TASK_NAME = "OpenAI AI Task" +DEFAULT_NAME = "OpenAI Conversation" + CONF_CHAT_MODEL = "chat_model" +CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PROMPT = "prompt" -CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" CONF_RECOMMENDED = "recommended" CONF_TEMPERATURE = "temperature" CONF_TOP_P = "top_p" +CONF_VERBOSITY = "verbosity" CONF_WEB_SEARCH = "web_search" CONF_WEB_SEARCH_USER_LOCATION = "user_location" CONF_WEB_SEARCH_CONTEXT_SIZE = "search_context_size" @@ -21,11 +29,13 @@ CONF_WEB_SEARCH_CITY = "city" CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" +RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" -RECOMMENDED_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TOP_P = 1.0 +RECOMMENDED_VERBOSITY = "medium" RECOMMENDED_WEB_SEARCH = False RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium" RECOMMENDED_WEB_SEARCH_USER_LOCATION = False @@ -42,11 +52,19 @@ UNSUPPORTED_MODELS: list[str] = [ "gpt-4o-mini-realtime-preview-2024-12-17", ] -WEB_SEARCH_MODELS: list[str] = [ - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4o", - "gpt-4o-search-preview", - "gpt-4o-mini", - "gpt-4o-mini-search-preview", +UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ + "gpt-3.5", + "gpt-4-turbo", + "gpt-4.1-nano", + "o1", + "o3-mini", ] + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} +RECOMMENDED_AI_TASK_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 8fea4613ce0..803825c2810 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,73 +1,18 @@ """Conversation support for OpenAI.""" -from collections.abc import AsyncGenerator, Callable -import json -from typing import Any, Literal, cast +from typing import Literal -import openai -from openai._streaming import AsyncStream -from openai.types.responses import ( - EasyInputMessageParam, - FunctionToolParam, - ResponseCompletedEvent, - ResponseErrorEvent, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionToolCallParam, - ResponseIncompleteEvent, - ResponseInputParam, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputMessageParam, - ResponseReasoningItem, - ResponseReasoningItemParam, - ResponseStreamEvent, - ResponseTextDeltaEvent, - ToolParam, - WebSearchToolParam, -) -from openai.types.responses.response_input_param import FunctionCallOutput -from openai.types.responses.web_search_tool_param import UserLocation -from voluptuous_openapi import convert - -from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry -from .const import ( - CONF_CHAT_MODEL, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_REASONING_EFFORT, - CONF_TEMPERATURE, - CONF_TOP_P, - CONF_WEB_SEARCH, - CONF_WEB_SEARCH_CITY, - CONF_WEB_SEARCH_CONTEXT_SIZE, - CONF_WEB_SEARCH_COUNTRY, - CONF_WEB_SEARCH_REGION, - CONF_WEB_SEARCH_TIMEZONE, - CONF_WEB_SEARCH_USER_LOCATION, - DOMAIN, - LOGGER, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_REASONING_EFFORT, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_TOP_P, - RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, -) +from .const import CONF_PROMPT, DOMAIN +from .entity import OpenAIBaseLLMEntity # Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( @@ -76,175 +21,29 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = OpenAIConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue - -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> FunctionToolParam: - """Format tool specification.""" - return FunctionToolParam( - type="function", - name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), - description=tool.description, - strict=False, - ) - - -def _convert_content_to_param( - content: conversation.Content, -) -> ResponseInputParam: - """Convert any native chat message for this agent to the native format.""" - messages: ResponseInputParam = [] - if isinstance(content, conversation.ToolResultContent): - return [ - FunctionCallOutput( - type="function_call_output", - call_id=content.tool_call_id, - output=json.dumps(content.tool_result), - ) - ] - - if content.content: - role: Literal["user", "assistant", "system", "developer"] = content.role - if role == "system": - role = "developer" - messages.append( - EasyInputMessageParam(type="message", role=role, content=content.content) + async_add_entities( + [OpenAIConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, ) - if isinstance(content, conversation.AssistantContent) and content.tool_calls: - messages.extend( - ResponseFunctionToolCallParam( - type="function_call", - name=tool_call.tool_name, - arguments=json.dumps(tool_call.tool_args), - call_id=tool_call.id, - ) - for tool_call in content.tool_calls - ) - return messages - - -async def _transform_stream( - chat_log: conversation.ChatLog, - result: AsyncStream[ResponseStreamEvent], - messages: ResponseInputParam, -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform an OpenAI delta stream into HA format.""" - async for event in result: - LOGGER.debug("Received event: %s", event) - - if isinstance(event, ResponseOutputItemAddedEvent): - if isinstance(event.item, ResponseOutputMessage): - yield {"role": event.item.role} - elif isinstance(event.item, ResponseFunctionToolCall): - # OpenAI has tool calls as individual events - # while HA puts tool calls inside the assistant message. - # We turn them into individual assistant content for HA - # to ensure that tools are called as soon as possible. - yield {"role": "assistant"} - current_tool_call = event.item - elif isinstance(event, ResponseOutputItemDoneEvent): - item = event.item.model_dump() - item.pop("status", None) - if isinstance(event.item, ResponseReasoningItem): - messages.append(cast(ResponseReasoningItemParam, item)) - elif isinstance(event.item, ResponseOutputMessage): - messages.append(cast(ResponseOutputMessageParam, item)) - elif isinstance(event.item, ResponseFunctionToolCall): - messages.append(cast(ResponseFunctionToolCallParam, item)) - elif isinstance(event, ResponseTextDeltaEvent): - yield {"content": event.delta} - elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): - current_tool_call.arguments += event.delta - elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): - current_tool_call.status = "completed" - yield { - "tool_calls": [ - llm.ToolInput( - id=current_tool_call.call_id, - tool_name=current_tool_call.name, - tool_args=json.loads(current_tool_call.arguments), - ) - ] - } - elif isinstance(event, ResponseCompletedEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - elif isinstance(event, ResponseIncompleteEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - - if ( - event.response.incomplete_details - and event.response.incomplete_details.reason - ): - reason: str = event.response.incomplete_details.reason - else: - reason = "unknown reason" - - if reason == "max_output_tokens": - reason = "max output tokens reached" - elif reason == "content_filter": - reason = "content filter triggered" - - raise HomeAssistantError(f"OpenAI response incomplete: {reason}") - elif isinstance(event, ResponseFailedEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - reason = "unknown reason" - if event.response.error is not None: - reason = event.response.error.message - raise HomeAssistantError(f"OpenAI response failed: {reason}") - elif isinstance(event, ResponseErrorEvent): - raise HomeAssistantError(f"OpenAI response error: {event.message}") - class OpenAIConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OpenAIBaseLLMEntity, ): """OpenAI conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True - def __init__(self, entry: OpenAIConfigEntry) -> None: + def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self._attr_unique_id = entry.entry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, - manufacturer="OpenAI", - model="ChatGPT", - entry_type=dr.DeviceEntryType.SERVICE, - ) - if self.entry.options.get(CONF_LLM_HASS_API): + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -257,13 +56,7 @@ class OpenAIConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -276,7 +69,7 @@ class OpenAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Process the user input and call the API.""" - options = self.entry.options + options = self.subentry.data try: await chat_log.async_provide_llm_data( @@ -290,103 +83,4 @@ class OpenAIConversationEntity( await self._async_handle_chat_log(chat_log) - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.entry.options - - tools: list[ToolParam] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - if options.get(CONF_WEB_SEARCH): - web_search = WebSearchToolParam( - type="web_search_preview", - search_context_size=options.get( - CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE - ), - ) - if options.get(CONF_WEB_SEARCH_USER_LOCATION): - web_search["user_location"] = UserLocation( - type="approximate", - city=options.get(CONF_WEB_SEARCH_CITY, ""), - region=options.get(CONF_WEB_SEARCH_REGION, ""), - country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), - timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), - ) - if tools is None: - tools = [] - tools.append(web_search) - - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - messages = [ - m - for content in chat_log.content - for m in _convert_content_to_param(content) - ] - - client = self.entry.runtime_data - - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "input": messages, - "max_output_tokens": options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": chat_log.conversation_id, - "stream": True, - } - if tools: - model_args["tools"] = tools - - if model.startswith("o"): - model_args["reasoning"] = { - "effort": options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - else: - model_args["store"] = False - - try: - result = await client.responses.create(**model_args) - except openai.RateLimitError as err: - LOGGER.error("Rate limited by OpenAI: %s", err) - raise HomeAssistantError("Rate limited or insufficient funds") from err - except openai.OpenAIError as err: - LOGGER.error("Error talking to OpenAI: %s", err) - raise HomeAssistantError("Error talking to OpenAI") from err - - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(chat_log, result, messages) - ): - if not isinstance(content, conversation.AssistantContent): - messages.extend(_convert_content_to_param(content)) - - if not chat_log.unresponded_tool_results: - break - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py new file mode 100644 index 00000000000..44d833c8e71 --- /dev/null +++ b/homeassistant/components/openai_conversation/entity.py @@ -0,0 +1,608 @@ +"""Base entity for OpenAI.""" + +from __future__ import annotations + +import base64 +from collections.abc import AsyncGenerator, Callable, Iterable +import json +from mimetypes import guess_file_type +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +import openai +from openai._streaming import AsyncStream +from openai.types.responses import ( + EasyInputMessageParam, + FunctionToolParam, + ResponseCodeInterpreterToolCall, + ResponseCompletedEvent, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseFunctionWebSearch, + ResponseFunctionWebSearchParam, + ResponseIncompleteEvent, + ResponseInputFileParam, + ResponseInputImageParam, + ResponseInputMessageContentListParam, + ResponseInputParam, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseReasoningItem, + ResponseReasoningItemParam, + ResponseReasoningSummaryTextDeltaEvent, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ToolParam, + WebSearchToolParam, +) +from openai.types.responses.response_create_params import ResponseCreateParamsStreaming +from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.tool_param import ( + CodeInterpreter, + CodeInterpreterContainerCodeInterpreterToolAuto, +) +from openai.types.responses.web_search_tool_param import UserLocation +import voluptuous as vol +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +from .const import ( + CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, + CONF_MAX_TOKENS, + CONF_REASONING_EFFORT, + CONF_TEMPERATURE, + CONF_TOP_P, + CONF_VERBOSITY, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, + RECOMMENDED_VERBOSITY, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, +) + +if TYPE_CHECKING: + from . import OpenAIConfigEntry + + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +def _adjust_schema(schema: dict[str, Any]) -> None: + """Adjust the schema to be compatible with OpenAI API.""" + if schema["type"] == "object": + schema.setdefault("strict", True) + schema.setdefault("additionalProperties", False) + if "properties" not in schema: + return + + if "required" not in schema: + schema["required"] = [] + + # Ensure all properties are required + for prop, prop_info in schema["properties"].items(): + _adjust_schema(prop_info) + if prop not in schema["required"]: + prop_info["type"] = [prop_info["type"], "null"] + schema["required"].append(prop) + + elif schema["type"] == "array": + if "items" not in schema: + return + + _adjust_schema(schema["items"]) + + +def _format_structured_output( + schema: vol.Schema, llm_api: llm.APIInstance | None +) -> dict[str, Any]: + """Format the schema to be compatible with OpenAI API.""" + result: dict[str, Any] = convert( + schema, + custom_serializer=( + llm_api.custom_serializer if llm_api else llm.selector_serializer + ), + ) + + _adjust_schema(result) + + return result + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> FunctionToolParam: + """Format tool specification.""" + return FunctionToolParam( + type="function", + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + description=tool.description, + strict=False, + ) + + +def _convert_content_to_param( + chat_content: Iterable[conversation.Content], +) -> ResponseInputParam: + """Convert any native chat message for this agent to the native format.""" + messages: ResponseInputParam = [] + reasoning_summary: list[str] = [] + web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {} + + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + if ( + content.tool_name == "web_search_call" + and content.tool_call_id in web_search_calls + ): + web_search_call = web_search_calls.pop(content.tool_call_id) + web_search_call["status"] = content.tool_result.get( # type: ignore[typeddict-item] + "status", "completed" + ) + messages.append(web_search_call) + else: + messages.append( + FunctionCallOutput( + type="function_call_output", + call_id=content.tool_call_id, + output=json.dumps(content.tool_result), + ) + ) + continue + + if content.content: + role: Literal["user", "assistant", "system", "developer"] = content.role + if role == "system": + role = "developer" + messages.append( + EasyInputMessageParam( + type="message", role=role, content=content.content + ) + ) + + if isinstance(content, conversation.AssistantContent): + if content.tool_calls: + for tool_call in content.tool_calls: + if ( + tool_call.external + and tool_call.tool_name == "web_search_call" + and "action" in tool_call.tool_args + ): + web_search_calls[tool_call.id] = ResponseFunctionWebSearchParam( + type="web_search_call", + id=tool_call.id, + action=tool_call.tool_args["action"], + status="completed", + ) + else: + messages.append( + ResponseFunctionToolCallParam( + type="function_call", + name=tool_call.tool_name, + arguments=json.dumps(tool_call.tool_args), + call_id=tool_call.id, + ) + ) + + if content.thinking_content: + reasoning_summary.append(content.thinking_content) + + if isinstance(content.native, ResponseReasoningItem): + messages.append( + ResponseReasoningItemParam( + type="reasoning", + id=content.native.id, + summary=[ + { + "type": "summary_text", + "text": summary, + } + for summary in reasoning_summary + ] + if content.thinking_content + else [], + encrypted_content=content.native.encrypted_content, + ) + ) + reasoning_summary = [] + + return messages + + +async def _transform_stream( + chat_log: conversation.ChatLog, + stream: AsyncStream[ResponseStreamEvent], +) -> AsyncGenerator[ + conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict +]: + """Transform an OpenAI delta stream into HA format.""" + last_summary_index = None + last_role: Literal["assistant", "tool_result"] | None = None + + async for event in stream: + LOGGER.debug("Received event: %s", event) + + if isinstance(event, ResponseOutputItemAddedEvent): + if isinstance(event.item, ResponseFunctionToolCall): + # OpenAI has tool calls as individual events + # while HA puts tool calls inside the assistant message. + # We turn them into individual assistant content for HA + # to ensure that tools are called as soon as possible. + yield {"role": "assistant"} + last_role = "assistant" + last_summary_index = None + current_tool_call = event.item + elif ( + isinstance(event.item, ResponseOutputMessage) + or ( + isinstance(event.item, ResponseReasoningItem) + and last_summary_index is not None + ) # Subsequent ResponseReasoningItem + or last_role != "assistant" + ): + yield {"role": "assistant"} + last_role = "assistant" + last_summary_index = None + elif isinstance(event, ResponseOutputItemDoneEvent): + if isinstance(event.item, ResponseReasoningItem): + yield { + "native": ResponseReasoningItem( + type="reasoning", + id=event.item.id, + summary=[], # Remove summaries + encrypted_content=event.item.encrypted_content, + ) + } + last_summary_index = len(event.item.summary) - 1 + elif isinstance(event.item, ResponseCodeInterpreterToolCall): + yield { + "tool_calls": [ + llm.ToolInput( + id=event.item.id, + tool_name="code_interpreter", + tool_args={ + "code": event.item.code, + "container": event.item.container_id, + }, + external=True, + ) + ] + } + yield { + "role": "tool_result", + "tool_call_id": event.item.id, + "tool_name": "code_interpreter", + "tool_result": { + "output": [output.to_dict() for output in event.item.outputs] # type: ignore[misc] + if event.item.outputs is not None + else None + }, + } + last_role = "tool_result" + elif isinstance(event.item, ResponseFunctionWebSearch): + yield { + "tool_calls": [ + llm.ToolInput( + id=event.item.id, + tool_name="web_search_call", + tool_args={ + "action": event.item.action.to_dict(), + }, + external=True, + ) + ] + } + yield { + "role": "tool_result", + "tool_call_id": event.item.id, + "tool_name": "web_search_call", + "tool_result": {"status": event.item.status}, + } + last_role = "tool_result" + elif isinstance(event, ResponseTextDeltaEvent): + yield {"content": event.delta} + elif isinstance(event, ResponseReasoningSummaryTextDeltaEvent): + # OpenAI can output several reasoning summaries + # in a single ResponseReasoningItem. We split them as separate + # AssistantContent messages. Only last of them will have + # the reasoning `native` field set. + if ( + last_summary_index is not None + and event.summary_index != last_summary_index + ): + yield {"role": "assistant"} + last_role = "assistant" + last_summary_index = event.summary_index + yield {"thinking_content": event.delta} + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + current_tool_call.arguments += event.delta + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + current_tool_call.status = "completed" + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call.call_id, + tool_name=current_tool_call.name, + tool_args=json.loads(current_tool_call.arguments), + ) + ] + } + elif isinstance(event, ResponseCompletedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + elif isinstance(event, ResponseIncompleteEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + + if ( + event.response.incomplete_details + and event.response.incomplete_details.reason + ): + reason: str = event.response.incomplete_details.reason + else: + reason = "unknown reason" + + if reason == "max_output_tokens": + reason = "max output tokens reached" + elif reason == "content_filter": + reason = "content filter triggered" + + raise HomeAssistantError(f"OpenAI response incomplete: {reason}") + elif isinstance(event, ResponseFailedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + reason = "unknown reason" + if event.response.error is not None: + reason = event.response.error.message + raise HomeAssistantError(f"OpenAI response failed: {reason}") + elif isinstance(event, ResponseErrorEvent): + raise HomeAssistantError(f"OpenAI response error: {event.message}") + + +class OpenAIBaseLLMEntity(Entity): + """OpenAI conversation agent.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="OpenAI", + model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + messages = _convert_content_to_param(chat_log.content) + + model_args = ResponseCreateParamsStreaming( + model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + input=messages, + max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + user=chat_log.conversation_id, + store=False, + stream=True, + ) + + if model_args["model"].startswith(("o", "gpt-5")): + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ), + "summary": "auto", + } + model_args["include"] = ["reasoning.encrypted_content"] + + if model_args["model"].startswith("gpt-5"): + model_args["text"] = { + "verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY) + } + + tools: list[ToolParam] = [] + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if options.get(CONF_WEB_SEARCH): + web_search = WebSearchToolParam( + type="web_search_preview", + search_context_size=options.get( + CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE + ), + ) + if options.get(CONF_WEB_SEARCH_USER_LOCATION): + web_search["user_location"] = UserLocation( + type="approximate", + city=options.get(CONF_WEB_SEARCH_CITY, ""), + region=options.get(CONF_WEB_SEARCH_REGION, ""), + country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), + timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), + ) + tools.append(web_search) + + if options.get(CONF_CODE_INTERPRETER): + tools.append( + CodeInterpreter( + type="code_interpreter", + container=CodeInterpreterContainerCodeInterpreterToolAuto( + type="auto" + ), + ) + ) + model_args.setdefault("include", []).append("code_interpreter_call.outputs") # type: ignore[union-attr] + + if tools: + model_args["tools"] = tools + + last_content = chat_log.content[-1] + + # Handle attachments by adding them to the last user message + if last_content.role == "user" and last_content.attachments: + files = await async_prepare_files_for_prompt( + self.hass, + [a.path for a in last_content.attachments], + ) + last_message = messages[-1] + assert ( + last_message["type"] == "message" + and last_message["role"] == "user" + and isinstance(last_message["content"], str) + ) + last_message["content"] = [ + {"type": "input_text", "text": last_message["content"]}, # type: ignore[list-item] + *files, # type: ignore[list-item] + ] + + if structure and structure_name: + model_args["text"] = { + "format": { + "type": "json_schema", + "name": slugify(structure_name), + "schema": _format_structured_output(structure, chat_log.llm_api), + }, + } + + client = self.entry.runtime_data + + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + stream = await client.responses.create(**model_args) + + messages.extend( + _convert_content_to_param( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, stream) + ) + ] + ) + ) + except openai.RateLimitError as err: + LOGGER.error("Rate limited by OpenAI: %s", err) + raise HomeAssistantError("Rate limited or insufficient funds") from err + except openai.OpenAIError as err: + if ( + isinstance(err, openai.APIError) + and err.type == "insufficient_quota" + ): + LOGGER.error("Insufficient funds for OpenAI: %s", err) + raise HomeAssistantError("Insufficient funds for OpenAI") from err + + LOGGER.error("Error talking to OpenAI: %s", err) + raise HomeAssistantError("Error talking to OpenAI") from err + + if not chat_log.unresponded_tool_results: + break + + +async def async_prepare_files_for_prompt( + hass: HomeAssistant, files: list[Path] +) -> ResponseInputMessageContentListParam: + """Append files to a prompt. + + Caller needs to ensure that the files are allowed. + """ + + def append_files_to_content() -> ResponseInputMessageContentListParam: + content: ResponseInputMessageContentListParam = [] + + for file_path in files: + if not file_path.exists(): + raise HomeAssistantError(f"`{file_path}` does not exist") + + mime_type, _ = guess_file_type(file_path) + + if not mime_type or not mime_type.startswith(("image/", "application/pdf")): + raise HomeAssistantError( + "Only images and PDF are supported by the OpenAI API," + f"`{file_path}` is not an image file or PDF" + ) + + base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8") + + if mime_type.startswith("image/"): + content.append( + ResponseInputImageParam( + type="input_image", + image_url=f"data:{mime_type};base64,{base64_file}", + detail="auto", + ) + ) + elif mime_type.startswith("application/pdf"): + content.append( + ResponseInputFileParam( + type="input_file", + filename=str(file_path), + file_data=f"data:{mime_type};base64,{base64_file}", + ) + ) + + return content + + return await hass.async_add_executor_job(append_files_to_content) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 84369eb15a2..38ebe205bd3 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -1,6 +1,6 @@ { "domain": "openai_conversation", - "name": "OpenAI Conversation", + "name": "OpenAI", "after_dependencies": ["assist_pipeline", "intent"], "codeowners": ["@balloob"], "config_flow": true, @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.76.2"] + "requirements": ["openai==1.99.5"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 351e82ec11f..304ef8b6bdc 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -11,52 +11,117 @@ "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + + "step": { + "init": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "prompt": "[%key:common::config_flow::data::prompt%]", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template." + } }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "advanced": { + "title": "Advanced settings", + "data": { + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "Maximum tokens to return in response", + "temperature": "Temperature", + "top_p": "Top P" + } + }, + "model": { + "title": "Model-specific options", + "data": { + "code_interpreter": "Enable code interpreter tool", + "reasoning_effort": "Reasoning effort", + "web_search": "Enable web search", + "search_context_size": "Search context size", + "user_location": "Include home location" + }, + "data_description": { + "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", + "web_search": "Allow the model to search the web for the latest information before generating a response", + "search_context_size": "High level guidance for the amount of context window space to use for the search", + "user_location": "Refine search results based on geography" + } } }, - "advanced": { - "title": "Advanced settings", - "data": { - "chat_model": "[%key:common::generic::model%]", - "max_tokens": "Maximum tokens to return in response", - "temperature": "Temperature", - "top_p": "Top P" - } + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled." }, - "model": { - "title": "Model-specific options", - "data": { - "reasoning_effort": "Reasoning effort", - "web_search": "Enable web search", - "search_context_size": "Search context size", - "user_location": "Include home location" - }, - "data_description": { - "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", - "web_search": "Allow the model to search the web for the latest information before generating a response", - "search_context_size": "High level guidance for the amount of context window space to use for the search", - "user_location": "Refine search results based on geography" - } + "error": { + "model_not_supported": "This model is not supported, please select a different model" } }, - "error": { - "model_not_supported": "This model is not supported, please select a different model" + "ai_task_data": { + "initiate_flow": { + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" + }, + "entry_type": "AI task", + "step": { + "init": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::openai_conversation::config_subentries::conversation::step::init::data::recommended%]" + } + }, + "advanced": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]", + "data": { + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]", + "temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]", + "top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]" + } + }, + "model": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::model::title%]", + "data": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]" + }, + "data_description": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]" + } + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]" + }, + "error": { + "model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]" + } } }, "selector": { "reasoning_effort": { "options": { + "minimal": "Minimal", "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" @@ -68,6 +133,13 @@ "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" } + }, + "verbosity": { + "options": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } } }, "services": { diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 9f8840b8487..8251a06bd00 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -48,7 +48,7 @@ async def async_setup_entry( device = hass.data[DOMAIN][config_entry.entry_id] - entity = OpenhomeDevice(hass, device) + entity = OpenhomeDevice(device) async_add_entities([entity]) @@ -100,9 +100,8 @@ class OpenhomeDevice(MediaPlayerEntity): _attr_state = MediaPlayerState.PLAYING _attr_available = True - def __init__(self, hass, device): + def __init__(self, device): """Initialise the Openhome device.""" - self.hass = hass self._device = device self._attr_unique_id = device.uuid() self._source_index = {} diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 68463e764f2..c7e107b1637 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -30,6 +31,7 @@ from .const import ( CONF_SET_PRECISION, DATA_GATEWAYS, DATA_OPENTHERM_GW, + DOMAIN, THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) @@ -75,7 +77,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_hvac_modes = [] + _attr_hvac_modes = [HVACMode.HEAT] _attr_name = None _attr_preset_modes = [] _attr_min_temp = 1 @@ -129,9 +131,11 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): if ch_active and flame_on: self._attr_hvac_action = HVACAction.HEATING self._attr_hvac_mode = HVACMode.HEAT + self._attr_hvac_modes = [HVACMode.HEAT] elif cooling_active: self._attr_hvac_action = HVACAction.COOLING self._attr_hvac_mode = HVACMode.COOL + self._attr_hvac_modes = [HVACMode.COOL] else: self._attr_hvac_action = HVACAction.IDLE @@ -182,6 +186,13 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): return PRESET_AWAY return PRESET_NONE + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="change_hvac_mode_not_supported", + ) + def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 8959e0facf9..f3938c81e7e 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -355,6 +355,9 @@ } }, "exceptions": { + "change_hvac_mode_not_supported": { + "message": "Changing HVAC mode is not supported." + }, "invalid_gateway_id": { "message": "Gateway {gw_id} not found or not loaded!" } diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 4c66778119e..76a32af13b0 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -69,6 +69,10 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): title=user_input[CONF_NAME], data=data, options=options ) + description_placeholders["doc_url"] = ( + "https://www.home-assistant.io/integrations/openweathermap/" + ) + schema = vol.Schema( { vol.Required(CONF_API_KEY): str, diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 87b7860afb5..2860abbe64c 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -51,6 +51,7 @@ from .const import ( ATTR_API_WEATHER, ATTR_API_WEATHER_CODE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, ATTRIBUTION, DOMAIN, @@ -93,6 +94,13 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key=ATTR_API_WIND_GUST, + name="Wind gust", + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( key=ATTR_API_WIND_BEARING, name="Wind bearing", diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 1aa161c87dc..51de5cf2244 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -17,7 +17,7 @@ "mode": "[%key:common::config_flow::data::mode%]", "name": "[%key:common::config_flow::data::name%]" }, - "description": "To generate API key go to https://openweathermap.org/appid" + "description": "To generate an API key, please refer to the [integration documentation]({doc_url})" } } }, diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 23c8e7a8136..088083ef5db 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -2,9 +2,13 @@ from __future__ import annotations +from opower import select_utility + from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from .const import CONF_UTILITY, DOMAIN from .coordinator import OpowerConfigEntry, OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -12,6 +16,25 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" + utility_name = entry.data[CONF_UTILITY] + try: + select_utility(utility_name) + except ValueError: + ir.async_create_issue( + hass, + DOMAIN, + f"unsupported_utility_{entry.entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_utility", + translation_placeholders={"utility": utility_name}, + data={ + "entry_id": entry.entry_id, + "utility": utility_name, + "title": entry.title, + }, + ) + return False coordinator = OpowerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 4753a77894e..b66c4c6870e 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -9,6 +9,8 @@ from typing import Any from opower import ( CannotConnect, InvalidAuth, + MfaChallenge, + MfaHandlerBase, Opower, create_cookie_jar, get_supported_utility_names, @@ -16,48 +18,34 @@ from opower import ( ) 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_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import VolDictType -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) +CONF_MFA_CODE = "mfa_code" +CONF_MFA_METHOD = "mfa_method" async def _validate_login( - hass: HomeAssistant, login_data: dict[str, str] -) -> dict[str, str]: - """Validate login data and return any errors.""" + hass: HomeAssistant, + data: Mapping[str, Any], +) -> None: + """Validate login data and raise exceptions on failure.""" api = Opower( async_create_clientsession(hass, cookie_jar=create_cookie_jar()), - login_data[CONF_UTILITY], - login_data[CONF_USERNAME], - login_data[CONF_PASSWORD], - login_data.get(CONF_TOTP_SECRET), + data[CONF_UTILITY], + data[CONF_USERNAME], + data[CONF_PASSWORD], + data.get(CONF_TOTP_SECRET), + data.get(CONF_LOGIN_DATA), ) - errors: dict[str, str] = {} - try: - await api.async_login() - except InvalidAuth: - _LOGGER.exception( - "Invalid auth when connecting to %s", login_data[CONF_UTILITY] - ) - errors["base"] = "invalid_auth" - except CannotConnect: - _LOGGER.exception("Could not connect to %s", login_data[CONF_UTILITY]) - errors["base"] = "cannot_connect" - return errors + await api.async_login() class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): @@ -67,75 +55,147 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new OpowerConfigFlow.""" - self.utility_info: dict[str, Any] | None = None + self._data: dict[str, Any] = {} + self.mfa_handler: MfaHandlerBase | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} + """Handle the initial step (select utility).""" if user_input is not None: - self._async_abort_entries_match( - { - CONF_UTILITY: user_input[CONF_UTILITY], - CONF_USERNAME: user_input[CONF_USERNAME], - } - ) - if select_utility(user_input[CONF_UTILITY]).accepts_mfa(): - self.utility_info = user_input - return await self.async_step_mfa() - - errors = await _validate_login(self.hass, user_input) - if not errors: - return self._async_create_opower_entry(user_input) + self._data[CONF_UTILITY] = user_input[CONF_UTILITY] + return await self.async_step_credentials() return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names())} + ), ) - async def async_step_mfa( + async def async_step_credentials( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle MFA step.""" - assert self.utility_info is not None + """Handle credentials step.""" errors: dict[str, str] = {} + utility = select_utility(self._data[CONF_UTILITY]) + if user_input is not None: - data = {**self.utility_info, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self._async_create_opower_entry(data) + self._data.update(user_input) - if errors: - schema = { - vol.Required( - CONF_USERNAME, default=self.utility_info[CONF_USERNAME] - ): str, - vol.Required(CONF_PASSWORD): str, - } - else: - schema = {} + self._async_abort_entries_match( + { + CONF_UTILITY: self._data[CONF_UTILITY], + CONF_USERNAME: self._data[CONF_USERNAME], + } + ) - schema[vol.Required(CONF_TOTP_SECRET)] = str + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self._async_create_opower_entry(self._data) + + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str return self.async_show_form( - step_id="mfa", - data_schema=vol.Schema(schema), + step_id="credentials", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema_dict), user_input + ), + errors=errors, + ) + + async def async_step_mfa_options( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle MFA options step.""" + errors: dict[str, str] = {} + assert self.mfa_handler is not None + + if user_input is not None: + method = user_input[CONF_MFA_METHOD] + try: + await self.mfa_handler.async_select_mfa_option(method) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return await self.async_step_mfa_code() + + mfa_options = await self.mfa_handler.async_get_mfa_options() + if not mfa_options: + return await self.async_step_mfa_code() + return self.async_show_form( + step_id="mfa_options", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_METHOD): vol.In(mfa_options)}), + user_input, + ), + errors=errors, + ) + + async def async_step_mfa_code( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle MFA code submission step.""" + assert self.mfa_handler is not None + errors: dict[str, str] = {} + if user_input is not None: + code = user_input[CONF_MFA_CODE] + try: + login_data = await self.mfa_handler.async_submit_mfa_code(code) + except InvalidAuth: + errors["base"] = "invalid_mfa_code" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + self._data[CONF_LOGIN_DATA] = login_data + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=self._data + ) + return self._async_create_opower_entry(self._data) + + return self.async_show_form( + step_id="mfa_code", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_CODE): str}), user_input + ), errors=errors, ) @callback - def _async_create_opower_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + def _async_create_opower_entry( + self, data: dict[str, Any], **kwargs: Any + ) -> ConfigFlowResult: """Create the config entry.""" return self.async_create_entry( title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", data=data, + **kwargs, ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - return await self.async_step_reauth_confirm() + reauth_entry = self._get_reauth_entry() + self._data = dict(reauth_entry.data) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: reauth_entry.title}, + ) async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None @@ -143,21 +203,34 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): """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: - data = {**reauth_entry.data, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self.async_update_reload_and_abort(reauth_entry, data=data) - schema: VolDictType = { - vol.Required(CONF_USERNAME): reauth_entry.data[CONF_USERNAME], + if user_input is not None: + self._data.update(user_input) + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort(reauth_entry, data=self._data) + + utility = select_utility(self._data[CONF_UTILITY]) + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } - if select_utility(reauth_entry.data[CONF_UTILITY]).accepts_mfa(): - schema[vol.Optional(CONF_TOTP_SECRET)] = str + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str + return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema(schema), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema_dict), self._data + ), errors=errors, description_placeholders={CONF_NAME: reauth_entry.title}, ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py index c07d41bbdcf..5da50b2b06f 100644 --- a/homeassistant/components/opower/const.py +++ b/homeassistant/components/opower/const.py @@ -4,3 +4,4 @@ DOMAIN = "opower" CONF_UTILITY = "utility" CONF_TOTP_SECRET = "totp_secret" +CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 189fa185cd1..e6fbbee0bb6 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -14,7 +14,7 @@ from opower import ( ReadResolution, create_cookie_jar, ) -from opower.exceptions import ApiException, CannotConnect, InvalidAuth +from opower.exceptions import ApiException, CannotConnect, InvalidAuth, MfaChallenge from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -36,7 +36,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,6 +69,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], config_entry.data.get(CONF_TOTP_SECRET), + config_entry.data.get(CONF_LOGIN_DATA), ) @callback @@ -90,7 +91,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # Given the infrequent updating (every 12h) # assume previous session has expired and re-login. await self.api.async_login() - except InvalidAuth as err: + except (InvalidAuth, MfaChallenge) as err: _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err except CannotConnect as err: diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 4e88c5a68cc..e127824ac19 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.12.4"] + "requirements": ["opower==0.15.2"] } diff --git a/homeassistant/components/opower/repairs.py b/homeassistant/components/opower/repairs.py new file mode 100644 index 00000000000..f78dee32194 --- /dev/null +++ b/homeassistant/components/opower/repairs.py @@ -0,0 +1,44 @@ +"""Repairs for Opower.""" + +from __future__ import annotations + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + + +class UnsupportedUtilityFixFlow(RepairsFlow): + """Handler for removing a configuration entry that uses an unsupported utility.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self._entry_id = data["entry_id"] + self._placeholders = data.copy() + self._placeholders.pop("entry_id") + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + await self.hass.config_entries.async_remove(self._entry_id) + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="confirm", description_placeholders=self._placeholders + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None +) -> RepairsFlow: + """Create flow.""" + assert issue_id.startswith("unsupported_utility") + assert data + return UnsupportedUtilityFixFlow(data) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 46aa9e9b318..9fc4d7e536a 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OpowerConfigEntry, OpowerCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class OpowerEntityDescription(SensorEntityDescription): @@ -38,7 +40,7 @@ class OpowerEntityDescription(SensorEntityDescription): ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( OpowerEntityDescription( key="elec_usage_to_date", - name="Current bill electric usage to date", + translation_key="elec_usage_to_date", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, # Not TOTAL_INCREASING because it can decrease for accounts with solar @@ -48,7 +50,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_forecasted_usage", - name="Current bill electric forecasted usage", + translation_key="elec_forecasted_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -57,7 +59,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_typical_usage", - name="Typical monthly electric usage", + translation_key="elec_typical_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -66,7 +68,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_cost_to_date", - name="Current bill electric cost to date", + translation_key="elec_cost_to_date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -75,7 +77,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_forecasted_cost", - name="Current bill electric forecasted cost", + translation_key="elec_forecasted_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -84,7 +86,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_typical_cost", - name="Typical monthly electric cost", + translation_key="elec_typical_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -93,7 +95,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_start_date", - name="Current bill electric start date", + translation_key="elec_start_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -101,7 +103,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_end_date", - name="Current bill electric end date", + translation_key="elec_end_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -111,7 +113,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( OpowerEntityDescription( key="gas_usage_to_date", - name="Current bill gas usage to date", + translation_key="gas_usage_to_date", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -120,7 +122,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_forecasted_usage", - name="Current bill gas forecasted usage", + translation_key="gas_forecasted_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -129,7 +131,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_typical_usage", - name="Typical monthly gas usage", + translation_key="gas_typical_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -138,7 +140,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_cost_to_date", - name="Current bill gas cost to date", + translation_key="gas_cost_to_date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -147,7 +149,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_forecasted_cost", - name="Current bill gas forecasted cost", + translation_key="gas_forecasted_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -156,7 +158,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_typical_cost", - name="Typical monthly gas cost", + translation_key="gas_typical_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -165,7 +167,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_start_date", - name="Current bill gas start date", + translation_key="gas_start_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -173,7 +175,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_end_date", - name="Current bill gas end date", + translation_key="gas_end_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -229,6 +231,7 @@ async def async_setup_entry( class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): """Representation of an Opower sensor.""" + _attr_has_entity_name = True entity_description: OpowerEntityDescription def __init__( @@ -249,8 +252,6 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): @property def native_value(self) -> StateType | date: """Return the state.""" - if self.coordinator.data is not None: - return self.entity_description.value_fn( - self.coordinator.data[self.utility_account_id] - ) - return None + return self.entity_description.value_fn( + self.coordinator.data[self.utility_account_id] + ) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 3af968cf789..813e1185467 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -3,15 +3,43 @@ "step": { "user": { "data": { - "utility": "Utility name", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "utility": "Utility name" + }, + "data_description": { + "utility": "The name of your utility provider" } }, - "mfa": { - "description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", + "credentials": { + "title": "Enter credentials", "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", "totp_secret": "TOTP secret" + }, + "data_description": { + "username": "The username for your utility account", + "password": "The password for your utility account", + "totp_secret": "This is not a 6-digit code. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation." + } + }, + "mfa_options": { + "title": "Multi-factor authentication", + "description": "Your account requires multi-factor authentication (MFA). Select a method to receive your security code.", + "data": { + "mfa_method": "MFA method" + }, + "data_description": { + "mfa_method": "How to receive your security code" + } + }, + "mfa_code": { + "title": "Enter security code", + "description": "Please enter the security code below to complete login.", + "data": { + "mfa_code": "Security code" + }, + "data_description": { + "mfa_code": "Typically a 6-digit code" } }, "reauth_confirm": { @@ -19,13 +47,19 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" + "totp_secret": "[%key:component::opower::config::step::credentials::data::totp_secret%]" + }, + "data_description": { + "username": "[%key:component::opower::config::step::credentials::data_description::username%]", + "password": "[%key:component::opower::config::step::credentials::data_description::password%]", + "totp_secret": "[%key:component::opower::config::step::credentials::data_description::totp_secret%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_mfa_code": "The security code is incorrect. Please try again." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", @@ -36,6 +70,69 @@ "return_to_grid_migration": { "title": "Return to grid statistics for account: {utility_account_id}", "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue." + }, + "unsupported_utility": { + "title": "Unsupported utility: {utility}", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::opower::issues::unsupported_utility::title%]", + "description": "The utility `{utility}` used by entry `{title}` is no longer supported by the Opower integration. Select **Submit** to remove this integration entry now." + } + } + } + } + }, + "entity": { + "sensor": { + "elec_usage_to_date": { + "name": "Current bill electric usage to date" + }, + "elec_forecasted_usage": { + "name": "Current bill electric forecasted usage" + }, + "elec_typical_usage": { + "name": "Typical monthly electric usage" + }, + "elec_cost_to_date": { + "name": "Current bill electric cost to date" + }, + "elec_forecasted_cost": { + "name": "Current bill electric forecasted cost" + }, + "elec_typical_cost": { + "name": "Typical monthly electric cost" + }, + "elec_start_date": { + "name": "Current bill electric start date" + }, + "elec_end_date": { + "name": "Current bill electric end date" + }, + "gas_usage_to_date": { + "name": "Current bill gas usage to date" + }, + "gas_forecasted_usage": { + "name": "Current bill gas forecasted usage" + }, + "gas_typical_usage": { + "name": "Typical monthly gas usage" + }, + "gas_cost_to_date": { + "name": "Current bill gas cost to date" + }, + "gas_forecasted_cost": { + "name": "Current bill gas forecasted cost" + }, + "gas_typical_cost": { + "name": "Typical monthly gas cost" + }, + "gas_start_date": { + "name": "Current bill gas start date" + }, + "gas_end_date": { + "name": "Current bill gas end date" + } } } } diff --git a/homeassistant/components/osoenergy/icons.json b/homeassistant/components/osoenergy/icons.json index 42d1f2cc480..be1bf0534db 100644 --- a/homeassistant/components/osoenergy/icons.json +++ b/homeassistant/components/osoenergy/icons.json @@ -22,6 +22,9 @@ "set_v40_min": { "service": "mdi:car-coolant-level" }, + "turn_away_mode_on": { + "service": "mdi:beach" + }, "turn_off": { "service": "mdi:water-boiler-off" }, diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index 6129aa379f7..b47fb0fe08a 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.1.5"] + "requirements": ["pyosoenergyapi==1.2.4"] } diff --git a/homeassistant/components/osoenergy/services.yaml b/homeassistant/components/osoenergy/services.yaml index 6c8f5512215..4cd91f3285f 100644 --- a/homeassistant/components/osoenergy/services.yaml +++ b/homeassistant/components/osoenergy/services.yaml @@ -237,6 +237,20 @@ set_v40_min: max: 550 step: 1 unit_of_measurement: L +turn_away_mode_on: + target: + entity: + domain: water_heater + fields: + duration_days: + required: true + example: 7 + selector: + number: + min: 1 + max: 365 + step: 1 + unit_of_measurement: days turn_off: target: entity: diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 465f3f15c6b..48b99749ca1 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -209,6 +209,16 @@ } } }, + "turn_away_mode_on": { + "name": "Set away mode", + "description": "Turns on away mode for the water heater", + "fields": { + "duration_days": { + "name": "Duration in days", + "description": "Number of days to keep away mode active (1-365)" + } + } + }, "turn_off": { "name": "Turn off heating", "description": "Turns off heating for one hour or until min temperature is reached", diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index 07820ee97d5..1f4ad9d06c5 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -26,6 +26,7 @@ from homeassistant.util.json import JsonValueType from .const import DOMAIN from .entity import OSOEnergyEntity +ATTR_DURATION_DAYS = "duration_days" ATTR_UNTIL_TEMP_LIMIT = "until_temp_limit" ATTR_V40MIN = "v40_min" CURRENT_OPERATION_MAP: dict[str, Any] = { @@ -44,6 +45,7 @@ CURRENT_OPERATION_MAP: dict[str, Any] = { SERVICE_GET_PROFILE = "get_profile" SERVICE_SET_PROFILE = "set_profile" SERVICE_SET_V40MIN = "set_v40_min" +SERVICE_TURN_AWAY_MODE_ON = "turn_away_mode_on" SERVICE_TURN_OFF = "turn_off" SERVICE_TURN_ON = "turn_on" @@ -69,6 +71,16 @@ async def async_setup_entry( supports_response=SupportsResponse.ONLY, ) + platform.async_register_entity_service( + SERVICE_TURN_AWAY_MODE_ON, + { + vol.Required(ATTR_DURATION_DAYS): vol.All( + vol.Coerce(int), vol.Range(min=1, max=365) + ), + }, + OSOEnergyWaterHeater.async_oso_turn_away_mode_on.__name__, + ) + service_set_profile_schema = cv.make_entity_service_schema( { vol.Optional(f"hour_{hour:02d}"): vol.All( @@ -164,7 +176,9 @@ class OSOEnergyWaterHeater( _attr_name = None _attr_supported_features = ( - WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.ON_OFF + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF ) _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -203,6 +217,11 @@ class OSOEnergyWaterHeater( """Return the current temperature of the heater.""" return self.entity_data.current_temperature + @property + def is_away_mode_on(self) -> bool: + """Return if the heater is in away mode.""" + return self.entity_data.isInPowerSave + @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" @@ -228,6 +247,14 @@ class OSOEnergyWaterHeater( """Return the maximum temperature.""" return self.entity_data.max_temperature + async def async_turn_away_mode_on(self) -> None: + """Turn on away mode.""" + await self.osoenergy.hotwater.enable_holiday_mode(self.entity_data) + + async def async_turn_away_mode_off(self) -> None: + """Turn off away mode.""" + await self.osoenergy.hotwater.disable_holiday_mode(self.entity_data) + async def async_turn_on(self, **kwargs) -> None: """Turn on hotwater.""" await self.osoenergy.hotwater.turn_on(self.entity_data, True) @@ -265,6 +292,12 @@ class OSOEnergyWaterHeater( """Handle the service call.""" await self.osoenergy.hotwater.set_v40_min(self.entity_data, v40_min) + async def async_oso_turn_away_mode_on(self, duration_days: int) -> None: + """Enable away mode with duration.""" + await self.osoenergy.hotwater.enable_holiday_mode( + self.entity_data, duration_days + ) + async def async_oso_turn_off(self, until_temp_limit) -> None: """Handle the service call.""" await self.osoenergy.hotwater.turn_off(self.entity_data, until_temp_limit) diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index c8f0fae3622..335ae7ba4ef 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -123,7 +123,7 @@ "sensor": { "battery": { "state": { - "full": "Full", + "full": "[%key:common::state::full%]", "low": "[%key:common::state::low%]", "normal": "[%key:common::state::normal%]", "medium": "[%key:common::state::medium%]", diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 2aa0879ffed..da1fc051608 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,7 +9,6 @@ LOGGER = logging.getLogger(__package__) REQUESTS = "requests" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 4e72f555603..3c7335de15b 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -7,6 +7,7 @@ from python_overseerr import OverseerrClient, OverseerrConnectionError import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,14 +18,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util.json import JsonValueType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - ATTR_REQUESTED_BY, - ATTR_SORT_ORDER, - ATTR_STATUS, - DOMAIN, - LOGGER, -) +from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index 0fea90b7ea3..da990be7173 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -96,7 +96,7 @@ async def _get_paperless_api( translation_key="forbidden", ) from err except InitializationError as err: - raise ConfigEntryError( + raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", ) from err diff --git a/homeassistant/components/paperless_ngx/manifest.json b/homeassistant/components/paperless_ngx/manifest.json index 0be3562c76f..43c61185f3a 100644 --- a/homeassistant/components/paperless_ngx/manifest.json +++ b/homeassistant/components/paperless_ngx/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pypaperless"], "quality_scale": "silver", - "requirements": ["pypaperless==4.1.0"] + "requirements": ["pypaperless==4.1.1"] } diff --git a/homeassistant/components/paperless_ngx/quality_scale.yaml b/homeassistant/components/paperless_ngx/quality_scale.yaml index 827d4425132..f0d3296da10 100644 --- a/homeassistant/components/paperless_ngx/quality_scale.yaml +++ b/homeassistant/components/paperless_ngx/quality_scale.yaml @@ -50,19 +50,19 @@ rules: discovery: status: exempt comment: Paperless does not support discovery. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + 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: Service type integration entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/paperless_ngx/sensor.py b/homeassistant/components/paperless_ngx/sensor.py index 5d6bfe1347e..fd066f23240 100644 --- a/homeassistant/components/paperless_ngx/sensor.py +++ b/homeassistant/components/paperless_ngx/sensor.py @@ -56,24 +56,28 @@ SENSOR_STATISTICS: tuple[PaperlessEntityDescription[Statistic], ...] = ( translation_key="characters_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.character_count, + entity_registry_enabled_default=False, ), PaperlessEntityDescription[Statistic]( key="tag_count", translation_key="tag_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.tag_count, + entity_registry_enabled_default=False, ), PaperlessEntityDescription[Statistic]( key="correspondent_count", translation_key="correspondent_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.correspondent_count, + entity_registry_enabled_default=False, ), PaperlessEntityDescription[Statistic]( key="document_type_count", translation_key="document_type_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.document_type_count, + entity_registry_enabled_default=False, ), ) @@ -141,6 +145,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( translation_key="index_status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, options=[ item.value.lower() for item in StatusType if item != StatusType.UNKNOWN ], @@ -159,6 +164,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( translation_key="classifier_status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, options=[ item.value.lower() for item in StatusType if item != StatusType.UNKNOWN ], @@ -177,6 +183,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( translation_key="celery_status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, options=[ item.value.lower() for item in StatusType if item != StatusType.UNKNOWN ], diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 1c71603e41e..c8388f40704 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_STATION +from .const import CONF_STATION, DOMAIN from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) try: station = await api.async_get_station_details(station_uuid) except CONNECT_ERRORS as err: - raise ConfigEntryNotReady("Failed to connect") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station) diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index 0a0f31532b1..c488eca34af 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], + "quality_scale": "platinum", "requirements": ["aiopegelonline==0.1.1"] } diff --git a/homeassistant/components/pegel_online/quality_scale.yaml b/homeassistant/components/pegel_online/quality_scale.yaml new file mode 100644 index 00000000000..aa0a153ee9c --- /dev/null +++ b/homeassistant/components/pegel_online/quality_scale.yaml @@ -0,0 +1,87 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions/services are implemented + 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 actions/services are implemented + 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 actions/services are implemented + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: has no options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: no authentication necessary + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: pure webservice, no discovery + discovery: + status: exempt + comment: pure webservice, no discovery + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: not applicable - see stale-devices + 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: | + each config entry represents only one named measurement station, + so when the user wants to add another one, they can just add another config entry + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: exempt + comment: | + does not apply, since only one measurement station per config-entry + if a measurement station is removed from the data provider, + the user can just remove the related config entry + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index ee2e6750911..30d4edfb041 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -20,6 +20,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PegelOnlineSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json index 7d0702754af..65fecbfb825 100644 --- a/homeassistant/components/pegel_online/strings.json +++ b/homeassistant/components/pegel_online/strings.json @@ -2,17 +2,23 @@ "config": { "step": { "user": { - "description": "Select the area in which you want to search for water measuring stations", "data": { "location": "[%key:common::config_flow::data::location%]", "radius": "Search radius" + }, + "data_description": { + "location": "Pick the location where to search for water measuring stations.", + "radius": "The radius to search for water measuring stations around the selected location." } }, "select_station": { - "title": "Select the measuring station to add", + "title": "Select the station to add", "description": "Found {stations_count} stations in radius", "data": { "station": "Station" + }, + "data_description": { + "station": "Select the water measuring station you want to add to Home Assistant." } } }, diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 856e07bb2ee..0dd8646b17e 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -27,7 +27,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, STATE_HOME, - STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -526,7 +525,7 @@ class Person( latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: latest_non_gps_home = _get_latest(latest_non_gps_home, state) - elif state.state == STATE_NOT_HOME: + else: latest_not_home = _get_latest(latest_not_home, state) if latest_non_gps_home: diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index 66b4439acd8..779452b284b 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -6,7 +6,13 @@ from collections.abc import Mapping import platform from typing import Any -from haphilipsjs import ConnectionFailure, PairingFailure, PhilipsTV +from haphilipsjs import ( + DEFAULT_API_VERSION, + ConnectionFailure, + GeneralFailure, + PairingFailure, + PhilipsTV, +) import voluptuous as vol from homeassistant.config_entries import ( @@ -18,16 +24,18 @@ from homeassistant.config_entries import ( from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PIN, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN @@ -54,21 +62,6 @@ OPTIONS_FLOW = { } -async def _validate_input( - hass: HomeAssistant, host: str, api_version: int -) -> PhilipsTV: - """Validate the user input allows us to connect.""" - hub = PhilipsTV(host, api_version) - - await hub.getSystem() - await hub.setTransport(hub.secured_transport) - - if not hub.system: - raise ConnectionFailure("System data is empty") - - return hub - - class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Philips TV.""" @@ -81,6 +74,38 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): self._hub: PhilipsTV | None = None self._pair_state: Any = None + async def _async_attempt_prepare( + self, host: str, api_version: int, secured_transport: bool + ) -> None: + hub = PhilipsTV( + host, api_version=api_version, secured_transport=secured_transport + ) + + await hub.getSystem() + await hub.setTransport(hub.secured_transport, hub.api_version_detected) + + if not hub.system or not hub.name: + raise ConnectionFailure("System data or name is empty") + + self._hub = hub + self._current[CONF_HOST] = host + self._current[CONF_SYSTEM] = hub.system + self._current[CONF_API_VERSION] = hub.api_version + self.context.update({"title_placeholders": {CONF_NAME: hub.name}}) + + if serialnumber := hub.system.get("serialnumber"): + await self.async_set_unique_id(serialnumber) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured( + updates=self._current, reload_on_update=True + ) + + async def _async_attempt_add(self) -> ConfigFlowResult: + assert self._hub + if self._hub.pairing_type == "digest_auth_pairing": + return await self.async_step_pair() + return await self._async_create_current() + async def _async_create_current(self) -> ConfigFlowResult: system = self._current[CONF_SYSTEM] if self.source == SOURCE_REAUTH: @@ -154,6 +179,43 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION] return await self.async_step_user() + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + LOGGER.debug( + "Checking discovered device: {discovery_info.name} on {discovery_info.host}" + ) + + secured_transport = discovery_info.type == "_philipstv_s_rpc._tcp.local." + api_version = 6 if secured_transport else DEFAULT_API_VERSION + + try: + await self._async_attempt_prepare( + discovery_info.host, api_version, secured_transport + ) + except GeneralFailure: + LOGGER.debug("Failed to get system info from discovery", exc_info=True) + return self.async_abort(reason="discovery_failure") + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + return await self._async_attempt_add() + + name = self.context.get("title_placeholders", {CONF_NAME: "Philips TV"})[ + CONF_NAME + ] + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: name}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -162,28 +224,14 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._current = user_input try: - hub = await _validate_input( - self.hass, user_input[CONF_HOST], user_input[CONF_API_VERSION] + await self._async_attempt_prepare( + user_input[CONF_HOST], user_input[CONF_API_VERSION], False ) - except ConnectionFailure as exc: + except GeneralFailure as exc: LOGGER.error(exc) errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" else: - if serialnumber := hub.system.get("serialnumber"): - await self.async_set_unique_id(serialnumber) - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - - self._current[CONF_SYSTEM] = hub.system - self._current[CONF_API_VERSION] = hub.api_version - self._hub = hub - - if hub.pairing_type == "digest_auth_pairing": - return await self.async_step_pair() - return await self._async_create_current() + return await self._async_attempt_add() schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index bba9a1a8762..0e88d6d44a9 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/philips_js", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.2.2"] + "requirements": ["ha-philipsjs==3.2.2"], + "zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."] } diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 1f187d89dda..6c5a1fcce0a 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "user": { "data": { @@ -7,6 +8,10 @@ "api_version": "API Version" } }, + "zeroconf_confirm": { + "title": "Discovered Philips TV", + "description": "Do you want to add the TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." + }, "pair": { "title": "Pair", "description": "Enter the PIN displayed on your TV", diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 5cc21cef3a9..ae51fe166c4 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -4,9 +4,10 @@ from __future__ import annotations from dataclasses import dataclass import logging +from typing import Any, Literal -from hole import Hole -from hole.exceptions import HoleError +from hole import Hole, HoleV5, HoleV6 +from hole.exceptions import HoleConnectionError, HoleError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -24,7 +25,12 @@ 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 +from .const import ( + CONF_STATISTICS_ONLY, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, + VERSION_6_RESPONSE_TO_5_ERROR, +) _LOGGER = logging.getLogger(__name__) @@ -45,16 +51,13 @@ class PiHoleData: 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] - use_tls = entry.data[CONF_SSL] - verify_tls = entry.data[CONF_VERIFY_SSL] - location = entry.data[CONF_LOCATION] - api_key = entry.data.get(CONF_API_KEY, "") # remove obsolet CONF_STATISTICS_ONLY from entry.data if CONF_STATISTICS_ONLY in entry.data: @@ -96,21 +99,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) - session = async_get_clientsession(hass, verify_tls) - api = Hole( - host, - session, - location=location, - tls=use_tls, - api_token=api_key, - ) + _LOGGER.debug("Determining Pi-hole API version for %s", host) + version = await determine_api_version(hass, dict(entry.data)) + _LOGGER.debug("Pi-hole API version determined: %s", version) + + # 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 from err raise UpdateFailed(f"Failed to communicate with API: {err}") from err if not isinstance(api.data, dict): raise ConfigEntryAuthFailed @@ -126,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo await coordinator.async_config_entry_first_refresh() - entry.runtime_data = PiHoleData(api, coordinator) + entry.runtime_data = PiHoleData(api, coordinator, version) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -136,3 +155,98 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Pi-hole entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def api_by_version( + hass: HomeAssistant, + entry: dict[str, Any], + version: int, + password: str | None = None, +) -> HoleV5 | HoleV6: + """Create a pi-hole API object by API version number. Once V5 is deprecated this function can be removed.""" + + if password is None: + password = entry.get(CONF_API_KEY, "") + session = async_get_clientsession(hass, entry[CONF_VERIFY_SSL]) + hole_kwargs = { + "host": entry[CONF_HOST], + "session": session, + "location": entry[CONF_LOCATION], + "verify_tls": entry[CONF_VERIFY_SSL], + "version": version, + } + if version == 5: + hole_kwargs["tls"] = entry.get(CONF_SSL) + hole_kwargs["api_token"] = password + elif version == 6: + hole_kwargs["protocol"] = "https" if entry.get(CONF_SSL) else "http" + hole_kwargs["password"] = password + + return Hole(**hole_kwargs) + + +async def determine_api_version( + hass: HomeAssistant, entry: dict[str, Any] +) -> Literal[5, 6]: + """Determine the API version of the Pi-hole instance without requiring authentication. + + Neither API v5 or v6 provides an endpoint to check the version without authentication. + Version 6 provides other enddpoints that do not require authentication, so we can use those to determine the version + version 5 returns an empty list in response to unauthenticated requests. + Because we are using endpoints that are not designed for this purpose, we should log liberally to help with debugging. + """ + + holeV6 = api_by_version(hass, entry, 6, password="wrong_password") + try: + await holeV6.authenticate() + except HoleConnectionError as err: + _LOGGER.error( + "Unexpected error connecting to Pi-hole v6 API at %s: %s. Trying version 5 API", + holeV6.base_url, + err, + ) + # Ideally python-hole would raise a specific exception for authentication failures + except HoleError as ex_v6: + if str(ex_v6) == "Authentication failed: Invalid password": + _LOGGER.debug( + "Success connecting to Pi-hole at %s without auth, API version is : %s", + holeV6.base_url, + 6, + ) + return 6 + _LOGGER.debug( + "Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6 + ) + else: + # It seems that occasionally the auth can succeed unexpectedly when there is a valid session + _LOGGER.warning( + "Authenticated with %s through v6 API, but succeeded with an incorrect password. This is a known bug", + holeV6.base_url, + ) + return 6 + holeV5 = api_by_version(hass, entry, 5, password="wrong_token") + try: + await holeV5.get_data() + + except HoleConnectionError as err: + _LOGGER.error( + "Failed to connect to Pi-hole v5 API at %s: %s", holeV5.base_url, err + ) + else: + # V5 API returns [] to unauthenticated requests + if not holeV5.data: + _LOGGER.debug( + "Response '[]' from API without auth, pihole API version 5 probably detected at %s", + holeV5.base_url, + ) + return 5 + _LOGGER.debug( + "Unexpected response from Pi-hole API at %s: %s", + holeV5.base_url, + str(holeV5.data), + ) + _LOGGER.debug( + "Could not determine pi-hole API version at: %s", + holeV6.base_url, + ) + raise HoleError("Could not determine Pi-hole API version") diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 1d12307b6e5..049195d01b1 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -33,7 +33,7 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( PiHoleBinarySensorEntityDescription( key="status", translation_key="status", - state_value=lambda api: bool(api.data.get("status") == "enabled"), + state_value=lambda api: bool(api.status == "enabled"), ), ) diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index e50b018caa4..327ce32847e 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -6,7 +6,6 @@ from collections.abc import Mapping import logging from typing import Any -from hole import Hole from hole.exceptions import HoleError import voluptuous as vol @@ -20,8 +19,8 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import Hole, api_by_version, determine_api_version from .const import ( DEFAULT_LOCATION, DEFAULT_NAME, @@ -55,6 +54,7 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): CONF_LOCATION: user_input[CONF_LOCATION], CONF_SSL: user_input[CONF_SSL], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_API_KEY: user_input[CONF_API_KEY], } self._async_abort_entries_match( @@ -69,9 +69,6 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): title=user_input[CONF_NAME], data=self._config ) - if CONF_API_KEY in errors: - return await self.async_step_api_key() - user_input = user_input or {} return self.async_show_form( step_id="user", @@ -88,6 +85,10 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): CONF_LOCATION, default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION), ): str, + vol.Required( + CONF_API_KEY, + default=user_input.get(CONF_API_KEY), + ): str, vol.Required( CONF_SSL, default=user_input.get(CONF_SSL, DEFAULT_SSL), @@ -101,25 +102,6 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_api_key( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle step to setup API key.""" - errors = {} - if user_input is not None: - self._config[CONF_API_KEY] = user_input[CONF_API_KEY] - if not (errors := await self._async_try_connect()): - return self.async_create_entry( - title=self._config[CONF_NAME], - data=self._config, - ) - - return self.async_show_form( - step_id="api_key", - data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), - errors=errors, - ) - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -151,19 +133,48 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): ) async def _async_try_connect(self) -> dict[str, str]: - session = async_get_clientsession(self.hass, self._config[CONF_VERIFY_SSL]) - pi_hole = Hole( - self._config[CONF_HOST], - session, - location=self._config[CONF_LOCATION], - tls=self._config[CONF_SSL], - api_token=self._config.get(CONF_API_KEY), - ) + """Try to connect to the Pi-hole API and determine the version.""" try: - await pi_hole.get_data() - except HoleError as ex: - _LOGGER.debug("Connection failed: %s", ex) + version = await determine_api_version(hass=self.hass, entry=self._config) + except HoleError: return {"base": "cannot_connect"} - if not isinstance(pi_hole.data, dict): - return {CONF_API_KEY: "invalid_auth"} + pi_hole: Hole = api_by_version(self.hass, self._config, version) + + if version == 6: + try: + await pi_hole.authenticate() + _LOGGER.debug("Success authenticating with pihole API version: %s", 6) + except HoleError: + _LOGGER.debug("Failed authenticating with pihole API version: %s", 6) + return {CONF_API_KEY: "invalid_auth"} + + elif version == 5: + try: + await pi_hole.get_data() + if pi_hole.data is not None and "error" in pi_hole.data: + _LOGGER.debug( + "API version %s returned an unexpected error: %s", + 5, + str(pi_hole.data), + ) + raise HoleError(pi_hole.data) # noqa: TRY301 + except HoleError as ex_v5: + _LOGGER.error( + "Connection to API version 5 failed: %s", + ex_v5, + ) + return {"base": "cannot_connect"} + else: + _LOGGER.debug( + "Success connecting to, but necessarily authenticating with, pihole, API version is: %s", + 5, + ) + # the v5 API returns an empty list to unauthenticated requests. + if not isinstance(pi_hole.data, dict): + _LOGGER.debug( + "API version %s returned %s, '[]' is expected for unauthenticated requests", + 5, + pi_hole.data, + ) + return {CONF_API_KEY: "invalid_auth"} return {} diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index c81e6504dff..5e91f348ce9 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -17,3 +17,10 @@ SERVICE_DISABLE = "disable" SERVICE_DISABLE_ATTR_DURATION = "duration" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +# See https://github.com/pi-hole/FTL/blob/88737f6248cd3df3202eed72aeec89b9fb572631/src/webserver/lua_web.c#L83 +VERSION_6_RESPONSE_TO_5_ERROR = { + "key": "bad_request", + "message": "Bad request", + "hint": "The API is hosted at pi.hole/api, not pi.hole/admin/api", +} diff --git a/homeassistant/components/pi_hole/entity.py b/homeassistant/components/pi_hole/entity.py index 0f5c6039232..f29aa819139 100644 --- a/homeassistant/components/pi_hole/entity.py +++ b/homeassistant/components/pi_hole/entity.py @@ -32,7 +32,10 @@ class PiHoleEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): @property def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" - if self.api.tls: + if ( + getattr(self.api, "tls", None) # API version 5 + or getattr(self.api, "protocol", None) == "https" # API version 6 + ): config_url = f"https://{self.api.host}/{self.api.location}" else: config_url = f"http://{self.api.host}/{self.api.location}" diff --git a/homeassistant/components/pi_hole/icons.json b/homeassistant/components/pi_hole/icons.json index 3a45f8ab454..d5c2e9a2d43 100644 --- a/homeassistant/components/pi_hole/icons.json +++ b/homeassistant/components/pi_hole/icons.json @@ -9,15 +9,24 @@ "ads_blocked_today": { "default": "mdi:close-octagon-outline" }, + "ads_blocked": { + "default": "mdi:close-octagon-outline" + }, "ads_percentage_today": { "default": "mdi:close-octagon-outline" }, + "percent_ads_blocked": { + "default": "mdi:close-octagon-outline" + }, "clients_ever_seen": { "default": "mdi:account-outline" }, "dns_queries_today": { "default": "mdi:comment-question-outline" }, + "dns_queries": { + "default": "mdi:comment-question-outline" + }, "domains_being_blocked": { "default": "mdi:block-helper" }, diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json index 975d8a1494c..aa8af024c5a 100644 --- a/homeassistant/components/pi_hole/manifest.json +++ b/homeassistant/components/pi_hole/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pi_hole", "iot_class": "local_polling", "loggers": ["hole"], - "requirements": ["hole==0.8.0"] + "requirements": ["hole==0.9.0"] } diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 54a9cb23d02..844b03acf7c 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -18,29 +21,98 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="ads_blocked_today", translation_key="ads_blocked_today", + suggested_display_precision=0, ), SensorEntityDescription( key="ads_percentage_today", translation_key="ads_percentage_today", native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, ), SensorEntityDescription( key="clients_ever_seen", translation_key="clients_ever_seen", + suggested_display_precision=0, ), SensorEntityDescription( - key="dns_queries_today", translation_key="dns_queries_today" + key="dns_queries_today", + translation_key="dns_queries_today", + suggested_display_precision=0, ), SensorEntityDescription( key="domains_being_blocked", translation_key="domains_being_blocked", + suggested_display_precision=0, ), - SensorEntityDescription(key="queries_cached", translation_key="queries_cached"), SensorEntityDescription( - key="queries_forwarded", translation_key="queries_forwarded" + key="queries_cached", + translation_key="queries_cached", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries_forwarded", + translation_key="queries_forwarded", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="unique_clients", + translation_key="unique_clients", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="unique_domains", + translation_key="unique_domains", + suggested_display_precision=0, + ), +) + +SENSOR_TYPES_V6: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="queries.blocked", + translation_key="ads_blocked", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.percent_blocked", + translation_key="percent_ads_blocked", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="clients.total", + translation_key="clients_ever_seen", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.total", + translation_key="dns_queries", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="gravity.domains_being_blocked", + translation_key="domains_being_blocked", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.cached", + translation_key="queries_cached", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.forwarded", + translation_key="queries_forwarded", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="clients.active", + translation_key="unique_clients", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.unique_domains", + translation_key="unique_domains", + suggested_display_precision=0, ), - SensorEntityDescription(key="unique_clients", translation_key="unique_clients"), - SensorEntityDescription(key="unique_domains", translation_key="unique_domains"), ) @@ -60,7 +132,9 @@ async def async_setup_entry( entry.entry_id, description, ) - for description in SENSOR_TYPES + for description in ( + SENSOR_TYPES if hole_data.api_version == 5 else SENSOR_TYPES_V6 + ) ] async_add_entities(sensors, True) @@ -88,7 +162,19 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the device.""" - try: - return round(self.api.data[self.entity_description.key], 2) # type: ignore[no-any-return] - except TypeError: - return self.api.data[self.entity_description.key] # type: ignore[no-any-return] + return get_nested(self.api.data, self.entity_description.key) + + +def get_nested(data: Mapping[str, Any], key: str) -> float | int: + """Get a value from a nested dictionary using a dot-separated key. + + Ensures type safety as it iterates into the dict. + """ + current: Any = data + for part in key.split("."): + if not isinstance(current, Mapping): + raise KeyError(f"Cannot access '{part}' in non-dict {current!r}") + current = current[part] + if not isinstance(current, (float, int)): + raise TypeError(f"Value at '{key}' is not a float or int: {current!r}") + return current diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 504be7a62dd..b3a634f4420 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -8,14 +8,11 @@ "name": "[%key:common::config_flow::data::name%]", "location": "[%key:common::config_flow::data::location%]", "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" - } - }, - "api_key": { - "data": { - "api_key": "[%key:common::config_flow::data::api_key%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "App password or API key" } }, + "reauth_confirm": { "title": "Reauthenticate Pi-hole", "description": "Please enter a new API key for Pi-hole at {host}/{location}", @@ -33,6 +30,12 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "issues": { + "v5_to_v6_migration": { + "title": "Recent migration from Pi-hole API v5 to v6", + "description": "You've likely updated your Pi-hole to API v6 from v5. Some sensors changed in the new API, the daily sensors were removed, and your old API token is invalid. Provide your new app password by re-authenticating in repairs or in **Settings -> Devices & services -> Pi-hole**." + } + }, "entity": { "binary_sensor": { "status": { @@ -44,9 +47,17 @@ "name": "Ads blocked today", "unit_of_measurement": "ads" }, + "ads_blocked": { + "name": "Ads blocked", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::ads_blocked_today::unit_of_measurement%]" + }, "ads_percentage_today": { "name": "Ads percentage blocked today" }, + + "percent_ads_blocked": { + "name": "Ads percentage blocked" + }, "clients_ever_seen": { "name": "Seen clients", "unit_of_measurement": "clients" @@ -55,6 +66,10 @@ "name": "DNS queries today", "unit_of_measurement": "queries" }, + "dns_queries": { + "name": "DNS queries", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]" + }, "domains_being_blocked": { "name": "Domains blocked", "unit_of_measurement": "domains" diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index 84ffe7e51a4..5fdb39bf9eb 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -70,7 +70,7 @@ class PiHoleSwitch(PiHoleEntity, SwitchEntity): @property def is_on(self) -> bool: """Return if the service is on.""" - return self.api.data.get("status") == "enabled" # type: ignore[no-any-return] + return self.api.status == "enabled" # type: ignore[no-any-return] async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the service.""" diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index 56e92b47289..90fdefd306b 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -21,9 +21,9 @@ from .entity import PiHoleEntity class PiHoleUpdateEntityDescription(UpdateEntityDescription): """Describes PiHole update entity.""" - installed_version: Callable[[dict], str | None] = lambda api: None - latest_version: Callable[[dict], str | None] = lambda api: None - has_update: Callable[[dict], bool | None] = lambda api: None + installed_version: Callable[[Hole], str | None] = lambda api: None + latest_version: Callable[[Hole], str | None] = lambda api: None + has_update: Callable[[Hole], bool | None] = lambda api: None release_base_url: str | None = None title: str | None = None @@ -34,9 +34,9 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( translation_key="core_update_available", title="Pi-hole Core", entity_category=EntityCategory.DIAGNOSTIC, - installed_version=lambda versions: versions.get("core_current"), - latest_version=lambda versions: versions.get("core_latest"), - has_update=lambda versions: versions.get("core_update"), + installed_version=lambda api: api.core_current, + latest_version=lambda api: api.core_latest, + has_update=lambda api: api.core_update, release_base_url="https://github.com/pi-hole/pi-hole/releases/tag", ), PiHoleUpdateEntityDescription( @@ -44,9 +44,9 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( translation_key="web_update_available", title="Pi-hole Web interface", entity_category=EntityCategory.DIAGNOSTIC, - installed_version=lambda versions: versions.get("web_current"), - latest_version=lambda versions: versions.get("web_latest"), - has_update=lambda versions: versions.get("web_update"), + installed_version=lambda api: api.web_current, + latest_version=lambda api: api.web_latest, + has_update=lambda api: api.web_update, release_base_url="https://github.com/pi-hole/AdminLTE/releases/tag", ), PiHoleUpdateEntityDescription( @@ -54,9 +54,9 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( translation_key="ftl_update_available", title="Pi-hole FTL DNS", entity_category=EntityCategory.DIAGNOSTIC, - installed_version=lambda versions: versions.get("FTL_current"), - latest_version=lambda versions: versions.get("FTL_latest"), - has_update=lambda versions: versions.get("FTL_update"), + installed_version=lambda api: api.ftl_current, + latest_version=lambda api: api.ftl_latest, + has_update=lambda api: api.ftl_update, release_base_url="https://github.com/pi-hole/FTL/releases/tag", ), ) @@ -108,15 +108,15 @@ class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): def installed_version(self) -> str | None: """Version installed and in use.""" if isinstance(self.api.versions, dict): - return self.entity_description.installed_version(self.api.versions) + return self.entity_description.installed_version(self.api) return None @property def latest_version(self) -> str | None: """Latest version available for install.""" if isinstance(self.api.versions, dict): - if self.entity_description.has_update(self.api.versions): - return self.entity_description.latest_version(self.api.versions) + if self.entity_description.has_update(self.api): + return self.entity_description.latest_version(self.api) return self.installed_version return None diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 4e8eafd8912..f8737806746 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -9,7 +9,6 @@ CONF_COORDINATOR = "coordinator" SERVICE_ADD_PRODUCT_TO_CART = "add_product" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PRODUCT_ID = "product_id" ATTR_PRODUCT_NAME = "product_name" ATTR_AMOUNT = "amount" diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 8ecae8dc301..d0465fcc13c 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,12 +7,12 @@ from typing import cast from python_picnic_api2 import PicnicAPI import voluptuous as vol +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, - ATTR_CONFIG_ENTRY_ID, ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS, ATTR_PRODUCT_NAME, diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 14203541359..f1d0113ac5e 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -50,16 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_reload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 27cb3f62bcd..d66f4beb8e5 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -71,12 +71,12 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Create the options flow.""" return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Ping.""" async def async_step_init( diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index 996faa99c5b..8000cbcddde 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -79,6 +79,7 @@ class PingDataICMPLib(PingData): "min": data.min_rtt, "max": data.max_rtt, "avg": data.avg_rtt, + "jitter": data.jitter, } diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py index 82d88064e02..b3866c9f0e7 100644 --- a/homeassistant/components/ping/sensor.py +++ b/homeassistant/components/ping/sensor.py @@ -71,6 +71,17 @@ SENSORS: tuple[PingSensorEntityDescription, ...] = ( value_fn=lambda result: result.data.get("min"), has_fn=lambda result: "min" in result.data, ), + PingSensorEntityDescription( + key="jitter", + translation_key="jitter", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda result: result.data.get("jitter"), + has_fn=lambda result: "jitter" in result.data, + ), ) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index c301a1b277d..4dc2e8ec7fc 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -12,6 +12,9 @@ }, "round_trip_time_min": { "name": "Round-trip time minimum" + }, + "jitter": { + "name": "Jitter" } } }, diff --git a/homeassistant/components/plaato/coordinator.py b/homeassistant/components/plaato/coordinator.py index df360d50068..74ff8566729 100644 --- a/homeassistant/components/plaato/coordinator.py +++ b/homeassistant/components/plaato/coordinator.py @@ -31,7 +31,6 @@ class PlaatoCoordinator(DataUpdateCoordinator): ) -> None: """Initialize.""" self.api = Plaato(auth_token=auth_token) - self.hass = hass self.device_type = device_type self.platforms: list[Platform] = [] diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index c111cf8c960..c2399c61f93 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -6,10 +6,23 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_NPSSO -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkFriendDataCoordinator, + PlaystationNetworkGroupsUpdateCoordinator, + PlaystationNetworkRuntimeData, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .helpers import PlaystationNetwork -PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.IMAGE, + Platform.MEDIA_PLAYER, + Platform.NOTIFY, + Platform.SENSOR, +] async def async_setup_entry( @@ -19,14 +32,41 @@ async def async_setup_entry( psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO]) - coordinator = PlaystationNetworkCoordinator(hass, psn, entry) + coordinator = PlaystationNetworkUserDataCoordinator(hass, psn, entry) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + + trophy_titles = PlaystationNetworkTrophyTitlesCoordinator(hass, psn, entry) + + groups = PlaystationNetworkGroupsUpdateCoordinator(hass, psn, entry) + await groups.async_config_entry_first_refresh() + + friends = {} + + for subentry_id, subentry in entry.subentries.items(): + friend_coordinator = PlaystationNetworkFriendDataCoordinator( + hass, psn, entry, subentry + ) + await friend_coordinator.async_config_entry_first_refresh() + friends[subentry_id] = friend_coordinator + + entry.runtime_data = PlaystationNetworkRuntimeData( + coordinator, trophy_titles, groups, friends + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True +async def _async_update_listener( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry( hass: HomeAssistant, entry: PlaystationNetworkConfigEntry ) -> bool: diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py new file mode 100644 index 00000000000..89a752eff0e --- /dev/null +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -0,0 +1,76 @@ +"""Binary Sensor platform for PlayStation Network integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkBinarySensorEntityDescription(BinarySensorEntityDescription): + """PlayStation Network binary sensor description.""" + + is_on_fn: Callable[[PlaystationNetworkData], bool] + + +class PlaystationNetworkBinarySensor(StrEnum): + """PlayStation Network binary sensors.""" + + PS_PLUS_STATUS = "ps_plus_status" + + +BINARY_SENSOR_DESCRIPTIONS: tuple[ + PlaystationNetworkBinarySensorEntityDescription, ... +] = ( + PlaystationNetworkBinarySensorEntityDescription( + key=PlaystationNetworkBinarySensor.PS_PLUS_STATUS, + translation_key=PlaystationNetworkBinarySensor.PS_PLUS_STATUS, + is_on_fn=lambda psn: psn.profile["isPlus"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + coordinator = config_entry.runtime_data.user_data + async_add_entities( + PlaystationNetworkBinarySensorEntity(coordinator, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class PlaystationNetworkBinarySensorEntity( + PlaystationNetworkServiceEntity, + BinarySensorEntity, +): + """Representation of a PlayStation Network binary sensor entity.""" + + entity_description: PlaystationNetworkBinarySensorEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index 29ba8d4de90..d7d82292378 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -10,14 +10,28 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPInvalidTokenError, PSNAWPNotFoundError, ) -from psnawp_api.models.user import User +from psnawp_api.models import User from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) -from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK +from .const import CONF_ACCOUNT_ID, CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK +from .coordinator import PlaystationNetworkConfigEntry from .helpers import PlaystationNetwork _LOGGER = logging.getLogger(__name__) @@ -28,6 +42,14 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str}) class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Playstation Network.""" + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"friend": FriendSubentryFlowHandler} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -42,7 +64,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): else: psn = PlaystationNetwork(self.hass, npsso) try: - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except PSNAWPNotFoundError: @@ -55,6 +77,15 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): else: await self.async_set_unique_id(user.account_id) self._abort_if_unique_id_configured() + config_entries = self.hass.config_entries.async_entries(DOMAIN) + for entry in config_entries: + if user.account_id in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort( + reason="already_configured_as_subentry" + ) + return self.async_create_entry( title=user.online_id, data={CONF_NPSSO: npsso}, @@ -76,19 +107,29 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure flow for PlayStation Network integration.""" + return await self.async_step_reauth_confirm(user_input) + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm reauthentication dialog.""" errors: dict[str, str] = {} - entry = self._get_reauth_entry() + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) if user_input is not None: try: npsso = parse_npsso_token(user_input[CONF_NPSSO]) psn = PlaystationNetwork(self.hass, npsso) - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except (PSNAWPNotFoundError, PSNAWPInvalidTokenError): @@ -113,7 +154,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="reauth_confirm", + step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure", data_schema=self.add_suggested_values_to_schema( data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input ), @@ -123,3 +164,65 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): "psn_link": PSN_LINK, }, ) + + +class FriendSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding a friend.""" + + friends_list: dict[str, User] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Subentry user flow.""" + config_entry: PlaystationNetworkConfigEntry = self._get_entry() + + if user_input is not None: + config_entries = self.hass.config_entries.async_entries(DOMAIN) + if user_input[CONF_ACCOUNT_ID] in { + entry.unique_id for entry in config_entries + }: + return self.async_abort(reason="already_configured_as_entry") + for entry in config_entries: + if user_input[CONF_ACCOUNT_ID] in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=self.friends_list[user_input[CONF_ACCOUNT_ID]].online_id, + data={}, + unique_id=user_input[CONF_ACCOUNT_ID], + ) + + self.friends_list = await self.hass.async_add_executor_job( + lambda: { + friend.account_id: friend + for friend in config_entry.runtime_data.user_data.psn.user.friends_list() + } + ) + + if not self.friends_list: + return self.async_abort(reason="no_friends") + + options = [ + SelectOptionDict( + value=friend.account_id, + label=friend.online_id, + ) + for friend in self.friends_list.values() + ] + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ACCOUNT_ID): SelectSelector( + SelectSelectorConfig(options=options) + ) + } + ), + user_input, + ), + ) diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index 77b43af3b73..df553a2ec01 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -6,11 +6,13 @@ from psnawp_api.models.trophies import PlatformType DOMAIN = "playstation_network" CONF_NPSSO: Final = "npsso" +CONF_ACCOUNT_ID: Final = "account_id" SUPPORTED_PLATFORMS = { - PlatformType.PS5, - PlatformType.PS4, + PlatformType.PS_VITA, PlatformType.PS3, + PlatformType.PS4, + PlatformType.PS5, PlatformType.PSPC, } diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 2581a016feb..977632de23b 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -2,18 +2,31 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging +from typing import TYPE_CHECKING, Any from psnawp_api.core.psnawp_exceptions import ( PSNAWPAuthenticationError, + PSNAWPClientError, + PSNAWPError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, PSNAWPServerError, ) -from psnawp_api.models.user import User +from psnawp_api.models import User +from psnawp_api.models.group.group_datatypes import GroupDetails +from psnawp_api.models.trophies import TrophyTitle -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -21,14 +34,24 @@ from .helpers import PlaystationNetwork, PlaystationNetworkData _LOGGER = logging.getLogger(__name__) -type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator] +type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkRuntimeData] -class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]): - """Data update coordinator for PSN.""" +@dataclass +class PlaystationNetworkRuntimeData: + """Dataclass holding PSN runtime data.""" + + user_data: PlaystationNetworkUserDataCoordinator + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator + groups: PlaystationNetworkGroupsUpdateCoordinator + friends: dict[str, PlaystationNetworkFriendDataCoordinator] + + +class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for PSN.""" config_entry: PlaystationNetworkConfigEntry - user: User + _update_inverval: timedelta def __init__( self, @@ -42,33 +65,165 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData name=DOMAIN, logger=_LOGGER, config_entry=config_entry, - update_interval=timedelta(seconds=30), + update_interval=self._update_interval, ) self.psn = psn + @abstractmethod + async def update_data(self) -> _DataT: + """Update coordinator data.""" + + async def _async_update_data(self) -> _DataT: + """Get the latest data from the PSN.""" + try: + return await self.update_data() + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + except (PSNAWPServerError, PSNAWPClientError) as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + + +class PlaystationNetworkUserDataCoordinator( + PlayStationNetworkBaseCoordinator[PlaystationNetworkData] +): + """Data update coordinator for PSN.""" + + _update_interval = timedelta(seconds=30) + async def _async_setup(self) -> None: """Set up the coordinator.""" try: - self.user = await self.psn.get_user() + await self.psn.async_setup() + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + except (PSNAWPServerError, PSNAWPClientError) as error: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + + async def update_data(self) -> PlaystationNetworkData: + """Get the latest data from the PSN.""" + return await self.psn.get_data() + + +class PlaystationNetworkTrophyTitlesCoordinator( + PlayStationNetworkBaseCoordinator[list[TrophyTitle]] +): + """Trophy titles data update coordinator for PSN.""" + + _update_interval = timedelta(days=1) + + async def update_data(self) -> list[TrophyTitle]: + """Update trophy titles data.""" + self.psn.trophy_titles = await self.hass.async_add_executor_job( + lambda: list(self.psn.user.trophy_titles(page_size=500)) + ) + await self.config_entry.runtime_data.user_data.async_request_refresh() + return self.psn.trophy_titles + + +class PlaystationNetworkGroupsUpdateCoordinator( + PlayStationNetworkBaseCoordinator[dict[str, GroupDetails]] +): + """Groups data update coordinator for PSN.""" + + _update_interval = timedelta(hours=3) + + async def update_data(self) -> dict[str, GroupDetails]: + """Update groups data.""" + return await self.hass.async_add_executor_job( + lambda: { + group_info.group_id: group_info.get_group_information() + for group_info in self.psn.client.get_groups() + if not group_info.group_id.startswith("~") + } + ) + + +class PlaystationNetworkFriendDataCoordinator( + PlayStationNetworkBaseCoordinator[PlaystationNetworkData] +): + """Friend status data update coordinator for PSN.""" + + user: User + profile: dict[str, Any] + + def __init__( + self, + hass: HomeAssistant, + psn: PlaystationNetwork, + config_entry: PlaystationNetworkConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the Coordinator.""" + self._update_interval = timedelta( + seconds=max(9 * len(config_entry.subentries), 180) + ) + super().__init__(hass, psn, config_entry) + self.subentry = subentry + + def _setup(self) -> None: + """Set up the coordinator.""" + if TYPE_CHECKING: + assert self.subentry.unique_id + self.user = self.psn.psn.user(account_id=self.subentry.unique_id) + self.profile = self.user.profile() + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + + try: + await self.hass.async_add_executor_job(self._setup) + except PSNAWPNotFoundError as error: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="user_not_found", + translation_placeholders={"user": self.subentry.title}, + ) from error + except PSNAWPAuthenticationError as error: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error - async def _async_update_data(self) -> PlaystationNetworkData: - """Get the latest data from the PSN.""" - try: - return await self.psn.get_data() - except PSNAWPAuthenticationError as error: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="not_ready", - ) from error - except PSNAWPServerError as error: - raise UpdateFailed( + except (PSNAWPServerError, PSNAWPClientError) as error: + _LOGGER.debug("Update failed", exc_info=True) + raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="update_failed", ) from error + + def _update_data(self) -> PlaystationNetworkData: + """Update friend status data.""" + try: + return PlaystationNetworkData( + username=self.user.online_id, + account_id=self.user.account_id, + presence=self.user.get_presence(), + profile=self.profile, + ) + except PSNAWPForbiddenError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="user_profile_private", + translation_placeholders={"user": self.subentry.title}, + ) from error + except PSNAWPError: + raise + + async def update_data(self) -> PlaystationNetworkData: + """Update friend status data.""" + return await self.hass.async_add_executor_job(self._update_data) diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py new file mode 100644 index 00000000000..710760a015c --- /dev/null +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -0,0 +1,63 @@ +"""Diagnostics support for PlayStation Network.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from psnawp_api.models.trophies import PlatformType + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import PlaystationNetworkConfigEntry + +TO_REDACT = { + "account_id", + "firstName", + "lastName", + "middleName", + "onlineId", + "url", + "username", + "onlineId", + "accountId", + "members", + "body", + "shareable_profile_link", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data.user_data + groups = entry.runtime_data.groups + return { + "data": async_redact_data( + _serialize_platform_types(asdict(coordinator.data)), TO_REDACT + ), + "groups": async_redact_data(groups.data, TO_REDACT), + } + + +def _serialize_platform_types(data: Any) -> Any: + """Recursively convert PlatformType enums to strings in dicts and sets.""" + if isinstance(data, dict): + return { + ( + platform.value if isinstance(platform, PlatformType) else platform + ): _serialize_platform_types(record) + for platform, record in data.items() + } + if isinstance(data, set): + return sorted( + [ + record.value if isinstance(record, PlatformType) else record + for record in data + ] + ) + if isinstance(data, PlatformType): + return data.value + return data diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py new file mode 100644 index 00000000000..dc1f126505c --- /dev/null +++ b/homeassistant/components/playstation_network/entity.py @@ -0,0 +1,54 @@ +"""Base entity for PlayStation Network Integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigSubentry +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PlayStationNetworkBaseCoordinator +from .helpers import PlaystationNetworkData + + +class PlaystationNetworkServiceEntity( + CoordinatorEntity[PlayStationNetworkBaseCoordinator] +): + """Common entity class for PlayStationNetwork Service entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PlayStationNetworkBaseCoordinator, + entity_description: EntityDescription, + subentry: ConfigSubentry | None = None, + ) -> None: + """Initialize PlayStation Network Service Entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id + self.entity_description = entity_description + self.subentry = subentry + unique_id = ( + subentry.unique_id + if subentry is not None and subentry.unique_id + else coordinator.config_entry.unique_id + ) + + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=( + coordinator.data.username + if isinstance(coordinator.data, PlaystationNetworkData) + else coordinator.psn.user.online_id + ), + entry_type=DeviceEntryType.SERVICE, + manufacturer="Sony Interactive Entertainment", + ) + if subentry: + self._attr_device_info.update( + DeviceInfo(via_device=(DOMAIN, coordinator.config_entry.unique_id)) + ) diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 38f8d5e1356..492a011cf78 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -7,9 +7,8 @@ from functools import partial from typing import Any from psnawp_api import PSNAWP -from psnawp_api.core.psnawp_exceptions import PSNAWPNotFoundError from psnawp_api.models.client import Client -from psnawp_api.models.trophies import PlatformType +from psnawp_api.models.trophies import PlatformType, TrophySummary, TrophyTitle from psnawp_api.models.user import User from pyrate_limiter import Duration, Rate @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from .const import SUPPORTED_PLATFORMS -LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4} +LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4, PlatformType.PS_VITA} @dataclass @@ -39,22 +38,40 @@ class PlaystationNetworkData: presence: dict[str, Any] = field(default_factory=dict) username: str = "" account_id: str = "" - available: bool = False active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict) registered_platforms: set[PlatformType] = field(default_factory=set) + trophy_summary: TrophySummary | None = None + profile: dict[str, Any] = field(default_factory=dict) + shareable_profile_link: dict[str, str] = field(default_factory=dict) class PlaystationNetwork: """Helper Class to return playstation network data in an easy to use structure.""" + shareable_profile_link: dict[str, str] + def __init__(self, hass: HomeAssistant, npsso: str) -> None: """Initialize the class with the npsso token.""" rate = Rate(300, Duration.MINUTE * 15) self.psn = PSNAWP(npsso, rate_limit=rate) - self.client: Client | None = None + self.client: Client self.hass = hass self.user: User self.legacy_profile: dict[str, Any] | None = None + self.trophy_titles: list[TrophyTitle] = [] + self._title_icon_urls: dict[str, str] = {} + self.friends_list: dict[str, User] | None = None + + def _setup(self) -> None: + """Setup PSN.""" + self.user = self.psn.user(online_id="me") + self.client = self.psn.me() + self.shareable_profile_link = self.client.get_shareable_profile_link() + self.trophy_titles = list(self.user.trophy_titles(page_size=500)) + + async def async_setup(self) -> None: + """Setup PSN.""" + await self.hass.async_add_executor_job(self._setup) async def get_user(self) -> User: """Get the user object from the PlayStation Network.""" @@ -67,9 +84,6 @@ class PlaystationNetwork: """Bundle api calls to retrieve data from the PlayStation Network.""" data = PlaystationNetworkData() - if not self.client: - self.client = self.psn.me() - data.registered_platforms = { PlatformType(device["deviceType"]) for device in self.client.get_account_devices() @@ -77,9 +91,13 @@ class PlaystationNetwork: data.presence = self.user.get_presence() + data.trophy_summary = self.client.trophy_summary() + data.profile = self.user.profile() + # check legacy platforms if owned if LEGACY_PLATFORMS & data.registered_platforms: self.legacy_profile = self.client.get_profile_legacy() + return data async def get_data(self) -> PlaystationNetworkData: @@ -87,65 +105,105 @@ class PlaystationNetwork: data = await self.hass.async_add_executor_job(self.retrieve_psn_data) data.username = self.user.online_id data.account_id = self.user.account_id + data.shareable_profile_link = self.shareable_profile_link - data.available = ( - data.presence.get("basicPresence", {}).get("availability") - == "availableToPlay" - ) - - session = SessionData() - session.platform = PlatformType( - data.presence["basicPresence"]["primaryPlatformInfo"]["platform"] - ) - - if session.platform in SUPPORTED_PLATFORMS: - session.status = data.presence.get("basicPresence", {}).get( - "primaryPlatformInfo" - )["onlineStatus"] - - game_title_info = data.presence.get("basicPresence", {}).get( - "gameTitleInfoList" + if "platform" in data.presence["basicPresence"]["primaryPlatformInfo"]: + primary_platform = PlatformType( + data.presence["basicPresence"]["primaryPlatformInfo"]["platform"] + ) + game_title_info: dict[str, Any] = next( + iter( + data.presence.get("basicPresence", {}).get("gameTitleInfoList", []) + ), + {}, + ) + status = data.presence.get("basicPresence", {}).get("primaryPlatformInfo")[ + "onlineStatus" + ] + title_format = ( + PlatformType(fmt) if (fmt := game_title_info.get("format")) else None ) - if game_title_info: - session.title_id = game_title_info[0]["npTitleId"] - session.title_name = game_title_info[0]["titleName"] - session.format = PlatformType(game_title_info[0]["format"]) - if session.format in {PlatformType.PS5, PlatformType.PSPC}: - session.media_image_url = game_title_info[0]["conceptIconUrl"] - else: - session.media_image_url = game_title_info[0]["npTitleIconUrl"] - - data.active_sessions[session.platform] = session + data.active_sessions[primary_platform] = SessionData( + platform=primary_platform, + status=status, + title_id=game_title_info.get("npTitleId"), + title_name=game_title_info.get("titleName"), + format=title_format, + media_image_url=( + game_title_info.get("conceptIconUrl") + or game_title_info.get("npTitleIconUrl") + ), + ) if self.legacy_profile: presence = self.legacy_profile["profile"].get("presences", []) - game_title_info = presence[0] if presence else {} - session = SessionData() + if (game_title_info := presence[0] if presence else {}) and game_title_info[ + "onlineStatus" + ] != "offline": + platform = PlatformType(game_title_info["platform"]) - # If primary console isn't online, check legacy platforms for status - if not data.available: - data.available = game_title_info["onlineStatus"] == "online" + if platform is PlatformType.PS4: + media_image_url = game_title_info.get("npTitleIconUrl") + elif platform is PlatformType.PS3 and game_title_info.get("npTitleId"): + media_image_url = self.psn.game_title( + game_title_info["npTitleId"], + platform=PlatformType.PS3, + account_id="me", + np_communication_id="", + ).get_title_icon_url() + elif platform is PlatformType.PS_VITA and game_title_info.get( + "npTitleId" + ): + media_image_url = self.get_psvita_title_icon_url(game_title_info) + else: + media_image_url = None - if "npTitleId" in game_title_info: - session.title_id = game_title_info["npTitleId"] - session.title_name = game_title_info["titleName"] - session.format = game_title_info["platform"] - session.platform = game_title_info["platform"] - session.status = game_title_info["onlineStatus"] - if PlatformType(session.format) is PlatformType.PS4: - session.media_image_url = game_title_info["npTitleIconUrl"] - elif PlatformType(session.format) is PlatformType.PS3: - try: - title = self.psn.game_title( - session.title_id, platform=PlatformType.PS3, account_id="me" - ) - except PSNAWPNotFoundError: - session.media_image_url = None - - if title: - session.media_image_url = title.get_title_icon_url() - - if game_title_info["onlineStatus"] == "online": - data.active_sessions[session.platform] = session + data.active_sessions[platform] = SessionData( + platform=platform, + title_id=game_title_info.get("npTitleId"), + title_name=game_title_info.get("titleName"), + format=platform, + media_image_url=media_image_url, + status=game_title_info["onlineStatus"], + ) return data + + def get_psvita_title_icon_url(self, game_title_info: dict[str, Any]) -> str | None: + """Look up title_icon_url from trophy titles data.""" + + if url := self._title_icon_urls.get(game_title_info["npTitleId"]): + return url + + url = next( + ( + title.title_icon_url + for title in self.trophy_titles + if game_title_info["titleName"] + == normalize_title(title.title_name or "") + and next(iter(title.title_platform)) == PlatformType.PS_VITA + ), + None, + ) + if url is not None: + self._title_icon_urls[game_title_info["npTitleId"]] = url + return url + + +def normalize_title(name: str) -> str: + """Normalize trophy title.""" + return name.removesuffix("Trophies").removesuffix("Trophy Set").strip() + + +def get_game_title_info(presence: dict[str, Any]) -> dict[str, Any]: + """Retrieve title info from presence.""" + + return ( + next((title for title in game_title_info), {}) + if ( + game_title_info := presence.get("basicPresence", {}).get( + "gameTitleInfoList" + ) + ) + else {} + ) diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2ff18bf6e59..5997f43fb5c 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -4,6 +4,65 @@ "playstation": { "default": "mdi:sony-playstation" } + }, + "binary_sensor": { + "ps_plus_status": { + "default": "mdi:shape-plus-outline" + } + }, + "sensor": { + "trophy_level": { + "default": "mdi:trophy-award" + }, + "trophy_level_progress": { + "default": "mdi:trending-up" + }, + "earned_trophies_platinum": { + "default": "mdi:trophy" + }, + "earned_trophies_gold": { + "default": "mdi:trophy-variant" + }, + "earned_trophies_silver": { + "default": "mdi:trophy-variant" + }, + "earned_trophies_bronze": { + "default": "mdi:trophy-variant" + }, + "online_id": { + "default": "mdi:account" + }, + "last_online": { + "default": "mdi:account-clock" + }, + "online_status": { + "default": "mdi:account-badge", + "state": { + "busy": "mdi:account-cancel", + "availabletocommunicate": "mdi:cellphone", + "offline": "mdi:account-off-outline" + } + }, + "now_playing": { + "default": "mdi:controller", + "state": { + "unknown": "mdi:controller-off", + "unavailable": "mdi:controller-off" + } + } + }, + "image": { + "share_profile": { + "default": "mdi:share-variant" + }, + "avatar": { + "default": "mdi:account-circle" + } + }, + "notify": { + "group_message": { + "default": "mdi:forum" + } } } } diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py new file mode 100644 index 00000000000..0a8e5daed62 --- /dev/null +++ b/homeassistant/components/playstation_network/image.py @@ -0,0 +1,154 @@ +"""Image platform for PlayStation Network.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import ( + PlayStationNetworkBaseCoordinator, + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkFriendDataCoordinator, + PlaystationNetworkUserDataCoordinator, +) +from .entity import PlaystationNetworkServiceEntity +from .helpers import get_game_title_info + +PARALLEL_UPDATES = 0 + + +class PlaystationNetworkImage(StrEnum): + """PlayStation Network images.""" + + AVATAR = "avatar" + SHARE_PROFILE = "share_profile" + NOW_PLAYING_IMAGE = "now_playing_image" + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkImageEntityDescription(ImageEntityDescription): + """Image entity description.""" + + image_url_fn: Callable[[PlaystationNetworkData], str | None] + + +IMAGE_DESCRIPTIONS_ME: tuple[PlaystationNetworkImageEntityDescription, ...] = ( + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.SHARE_PROFILE, + translation_key=PlaystationNetworkImage.SHARE_PROFILE, + image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"], + ), +) +IMAGE_DESCRIPTIONS_ALL: tuple[PlaystationNetworkImageEntityDescription, ...] = ( + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.AVATAR, + translation_key=PlaystationNetworkImage.AVATAR, + image_url_fn=( + lambda data: next( + ( + pic.get("url") + for pic in data.profile["avatars"] + if pic.get("size") == "xl" + ), + None, + ) + ), + ), + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.NOW_PLAYING_IMAGE, + translation_key=PlaystationNetworkImage.NOW_PLAYING_IMAGE, + image_url_fn=( + lambda data: get_game_title_info(data.presence).get("conceptIconUrl") + or get_game_title_info(data.presence).get("npTitleIconUrl") + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up image platform.""" + + coordinator = config_entry.runtime_data.user_data + + async_add_entities( + [ + PlaystationNetworkImageEntity(hass, coordinator, description) + for description in IMAGE_DESCRIPTIONS_ME + IMAGE_DESCRIPTIONS_ALL + ] + ) + + for ( + subentry_id, + friend_data_coordinator, + ) in config_entry.runtime_data.friends.items(): + async_add_entities( + [ + PlaystationNetworkFriendImageEntity( + hass, + friend_data_coordinator, + description, + config_entry.subentries[subentry_id], + ) + for description in IMAGE_DESCRIPTIONS_ALL + ], + config_subentry_id=subentry_id, + ) + + +class PlaystationNetworkImageBaseEntity(PlaystationNetworkServiceEntity, ImageEntity): + """An image entity.""" + + entity_description: PlaystationNetworkImageEntityDescription + coordinator: PlayStationNetworkBaseCoordinator + + def __init__( + self, + hass: HomeAssistant, + coordinator: PlayStationNetworkBaseCoordinator, + entity_description: PlaystationNetworkImageEntityDescription, + subentry: ConfigSubentry | None = None, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, entity_description, subentry) + ImageEntity.__init__(self, hass) + + self._attr_image_url = self.entity_description.image_url_fn(coordinator.data) + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if TYPE_CHECKING: + assert isinstance(self.coordinator.data, PlaystationNetworkData) + url = self.entity_description.image_url_fn(self.coordinator.data) + + if url != self._attr_image_url: + self._attr_image_url = url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + + super()._handle_coordinator_update() + + +class PlaystationNetworkImageEntity(PlaystationNetworkImageBaseEntity): + """An image entity.""" + + coordinator: PlaystationNetworkUserDataCoordinator + + +class PlaystationNetworkFriendImageEntity(PlaystationNetworkImageBaseEntity): + """An image entity.""" + + coordinator: PlaystationNetworkFriendDataCoordinator diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index bdcb77f92c3..590bd73fbf7 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -1,7 +1,7 @@ { "domain": "playstation_network", "name": "PlayStation Network", - "codeowners": ["@jackjpowell"], + "codeowners": ["@jackjpowell", "@tr4nt0r"], "config_flow": true, "dhcp": [ { @@ -60,6 +60,21 @@ }, { "macaddress": "D44B5E*" + }, + { + "macaddress": "F8D0AC*" + }, + { + "macaddress": "E86E3A*" + }, + { + "macaddress": "FC0FE6*" + }, + { + "macaddress": "9C37CB*" + }, + { + "macaddress": "84E657*" } ], "documentation": "https://www.home-assistant.io/integrations/playstation_network", diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 08840fbbabd..bdbc2a5ddd4 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -1,6 +1,7 @@ """Media player entity for the PlayStation Network Integration.""" import logging +from typing import TYPE_CHECKING from psnawp_api.models.trophies import PlatformType @@ -16,13 +17,18 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from . import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .const import DOMAIN, SUPPORTED_PLATFORMS _LOGGER = logging.getLogger(__name__) PLATFORM_MAP = { + PlatformType.PS_VITA: "PlayStation Vita", PlatformType.PS5: "PlayStation 5", PlatformType.PS4: "PlayStation 4", PlatformType.PS3: "PlayStation 3", @@ -37,7 +43,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Media Player Entity Setup.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data + trophy_titles = config_entry.runtime_data.trophy_titles devices_added: set[PlatformType] = set() device_reg = dr.async_get(hass) entities = [] @@ -49,10 +56,12 @@ async def async_setup_entry( if not SUPPORTED_PLATFORMS - devices_added: remove_listener() - new_platforms = set(coordinator.data.active_sessions.keys()) - devices_added + new_platforms = ( + set(coordinator.data.active_sessions.keys()) & SUPPORTED_PLATFORMS + ) - devices_added if new_platforms: async_add_entities( - PsnMediaPlayerEntity(coordinator, platform_type) + PsnMediaPlayerEntity(coordinator, platform_type, trophy_titles) for platform_type in new_platforms ) devices_added |= new_platforms @@ -63,7 +72,7 @@ async def async_setup_entry( (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}") } ): - entities.append(PsnMediaPlayerEntity(coordinator, platform)) + entities.append(PsnMediaPlayerEntity(coordinator, platform, trophy_titles)) devices_added.add(platform) if entities: async_add_entities(entities) @@ -73,7 +82,7 @@ async def async_setup_entry( class PsnMediaPlayerEntity( - CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity + CoordinatorEntity[PlaystationNetworkUserDataCoordinator], MediaPlayerEntity ): """Media player entity representing currently playing game.""" @@ -85,11 +94,15 @@ class PsnMediaPlayerEntity( _attr_name = None def __init__( - self, coordinator: PlaystationNetworkCoordinator, platform: PlatformType + self, + coordinator: PlaystationNetworkUserDataCoordinator, + platform: PlatformType, + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator, ) -> None: """Initialize PSN MediaPlayer.""" super().__init__(coordinator) - + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{platform.value}" self.key = platform self._attr_device_info = DeviceInfo( @@ -97,16 +110,21 @@ class PsnMediaPlayerEntity( name=PLATFORM_MAP[platform], manufacturer="Sony Interactive Entertainment", model=PLATFORM_MAP[platform], + via_device=(DOMAIN, coordinator.config_entry.unique_id), ) + self.trophy_titles = trophy_titles @property def state(self) -> MediaPlayerState: """Media Player state getter.""" session = self.coordinator.data.active_sessions.get(self.key) - if session and session.status == "online": - if self.coordinator.data.available and session.title_id is not None: - return MediaPlayerState.PLAYING - return MediaPlayerState.ON + if session: + if session.status == "online": + return ( + MediaPlayerState.PLAYING + if session.title_id is not None + else MediaPlayerState.ON + ) return MediaPlayerState.OFF @property @@ -126,3 +144,12 @@ class PsnMediaPlayerEntity( """Media image url getter.""" session = self.coordinator.data.active_sessions.get(self.key) return session.media_image_url if session else None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + if self.key is PlatformType.PS_VITA: + self.async_on_remove( + self.trophy_titles.async_add_listener(self._handle_coordinator_update) + ) diff --git a/homeassistant/components/playstation_network/notify.py b/homeassistant/components/playstation_network/notify.py new file mode 100644 index 00000000000..a06359ebffc --- /dev/null +++ b/homeassistant/components/playstation_network/notify.py @@ -0,0 +1,179 @@ +"""Notify platform for PlayStation Network.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import TYPE_CHECKING + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +from psnawp_api.models.group.group import Group + +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + NotifyEntity, + NotifyEntityDescription, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkFriendDataCoordinator, + PlaystationNetworkGroupsUpdateCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 20 + + +class PlaystationNetworkNotify(StrEnum): + """PlayStation Network sensors.""" + + GROUP_MESSAGE = "group_message" + DIRECT_MESSAGE = "direct_message" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notify entity platform.""" + + coordinator = config_entry.runtime_data.groups + + groups_added: set[str] = set() + entity_registry = er.async_get(hass) + + @callback + def add_entities() -> None: + nonlocal groups_added + + new_groups = set(coordinator.data.keys()) - groups_added + if new_groups: + async_add_entities( + PlaystationNetworkNotifyEntity(coordinator, group_id) + for group_id in new_groups + ) + groups_added |= new_groups + + deleted_groups = groups_added - set(coordinator.data.keys()) + for group_id in deleted_groups: + if entity_id := entity_registry.async_get_entity_id( + NOTIFY_DOMAIN, + DOMAIN, + f"{coordinator.config_entry.unique_id}_{group_id}", + ): + entity_registry.async_remove(entity_id) + + coordinator.async_add_listener(add_entities) + add_entities() + + for subentry_id, friend_coordinator in config_entry.runtime_data.friends.items(): + async_add_entities( + [ + PlaystationNetworkDirectMessageNotifyEntity( + friend_coordinator, + config_entry.subentries[subentry_id], + ) + ], + config_subentry_id=subentry_id, + ) + + +class PlaystationNetworkNotifyBaseEntity(PlaystationNetworkServiceEntity, NotifyEntity): + """Base class of PlayStation Network notify entity.""" + + group: Group | None = None + + def send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + if TYPE_CHECKING: + assert self.group + try: + self.group.send_message(message) + except PSNAWPNotFoundError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="group_invalid", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except PSNAWPForbiddenError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_forbidden", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except (PSNAWPServerError, PSNAWPClientError) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders=dict(self.translation_placeholders), + ) from e + + +class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity): + """Representation of a PlayStation Network notify entity.""" + + coordinator: PlaystationNetworkGroupsUpdateCoordinator + + def __init__( + self, + coordinator: PlaystationNetworkGroupsUpdateCoordinator, + group_id: str, + ) -> None: + """Initialize a notification entity.""" + self.group = coordinator.psn.psn.group(group_id=group_id) + group_details = coordinator.data[group_id] + self.entity_description = NotifyEntityDescription( + key=group_id, + translation_key=PlaystationNetworkNotify.GROUP_MESSAGE, + translation_placeholders={ + "group_name": group_details["groupName"]["value"] + or ", ".join( + member["onlineId"] + for member in group_details["members"] + if member["accountId"] != coordinator.psn.user.account_id + ) + }, + ) + + super().__init__(coordinator, self.entity_description) + + +class PlaystationNetworkDirectMessageNotifyEntity(PlaystationNetworkNotifyBaseEntity): + """Representation of a PlayStation Network notify entity for sending direct messages.""" + + coordinator: PlaystationNetworkFriendDataCoordinator + + def __init__( + self, + coordinator: PlaystationNetworkFriendDataCoordinator, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + + self.entity_description = NotifyEntityDescription( + key=PlaystationNetworkNotify.DIRECT_MESSAGE, + translation_key=PlaystationNetworkNotify.DIRECT_MESSAGE, + ) + + super().__init__(coordinator, self.entity_description, subentry) + + def send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + + if not self.group: + self.group = self.coordinator.psn.psn.group( + users_list=[self.coordinator.user] + ) + super().send_message(message, title) diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index e173c4a710c..954276e7243 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -44,7 +44,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Discovery flow is not applicable for this integration @@ -63,7 +63,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo # Platinum diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py new file mode 100644 index 00000000000..16d1ff13906 --- /dev/null +++ b/homeassistant/components/playstation_network/sensor.py @@ -0,0 +1,222 @@ +"""Sensor platform for PlayStation Network integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .coordinator import ( + PlayStationNetworkBaseCoordinator, + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkFriendDataCoordinator, + PlaystationNetworkUserDataCoordinator, +) +from .entity import PlaystationNetworkServiceEntity +from .helpers import get_game_title_info + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkSensorEntityDescription(SensorEntityDescription): + """PlayStation Network sensor description.""" + + value_fn: Callable[[PlaystationNetworkData], StateType | datetime] + available_fn: Callable[[PlaystationNetworkData], bool] = lambda _: True + + +class PlaystationNetworkSensor(StrEnum): + """PlayStation Network sensors.""" + + TROPHY_LEVEL = "trophy_level" + TROPHY_LEVEL_PROGRESS = "trophy_level_progress" + EARNED_TROPHIES_PLATINUM = "earned_trophies_platinum" + EARNED_TROPHIES_GOLD = "earned_trophies_gold" + EARNED_TROPHIES_SILVER = "earned_trophies_silver" + EARNED_TROPHIES_BRONZE = "earned_trophies_bronze" + ONLINE_ID = "online_id" + LAST_ONLINE = "last_online" + ONLINE_STATUS = "online_status" + NOW_PLAYING = "now_playing" + + +SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.TROPHY_LEVEL, + translation_key=PlaystationNetworkSensor.TROPHY_LEVEL, + value_fn=( + lambda psn: psn.trophy_summary.trophy_level if psn.trophy_summary else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.TROPHY_LEVEL_PROGRESS, + translation_key=PlaystationNetworkSensor.TROPHY_LEVEL_PROGRESS, + value_fn=( + lambda psn: psn.trophy_summary.progress if psn.trophy_summary else None + ), + native_unit_of_measurement=PERCENTAGE, + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_PLATINUM, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_PLATINUM, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.platinum + if psn.trophy_summary + else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_GOLD, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_GOLD, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.gold + if psn.trophy_summary + else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_SILVER, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_SILVER, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.silver + if psn.trophy_summary + else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_BRONZE, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_BRONZE, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.bronze + if psn.trophy_summary + else None + ), + ), +) +SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.ONLINE_ID, + translation_key=PlaystationNetworkSensor.ONLINE_ID, + value_fn=lambda psn: psn.username, + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.LAST_ONLINE, + translation_key=PlaystationNetworkSensor.LAST_ONLINE, + value_fn=( + lambda psn: dt_util.parse_datetime( + psn.presence["basicPresence"]["lastAvailableDate"] + ) + ), + available_fn=lambda psn: "lastAvailableDate" in psn.presence["basicPresence"], + device_class=SensorDeviceClass.TIMESTAMP, + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.ONLINE_STATUS, + translation_key=PlaystationNetworkSensor.ONLINE_STATUS, + value_fn=( + lambda psn: psn.presence["basicPresence"]["availability"] + .lower() + .replace("unavailable", "offline") + ), + device_class=SensorDeviceClass.ENUM, + options=["offline", "availabletoplay", "availabletocommunicate", "busy"], + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.NOW_PLAYING, + translation_key=PlaystationNetworkSensor.NOW_PLAYING, + value_fn=lambda psn: get_game_title_info(psn.presence).get("titleName"), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data.user_data + async_add_entities( + PlaystationNetworkSensorEntity(coordinator, description) + for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER + ) + + for ( + subentry_id, + friend_data_coordinator, + ) in config_entry.runtime_data.friends.items(): + async_add_entities( + [ + PlaystationNetworkFriendSensorEntity( + friend_data_coordinator, + description, + config_entry.subentries[subentry_id], + ) + for description in SENSOR_DESCRIPTIONS_USER + ], + config_subentry_id=subentry_id, + ) + + +class PlaystationNetworkSensorBaseEntity( + PlaystationNetworkServiceEntity, + SensorEntity, +): + """Base sensor entity.""" + + entity_description: PlaystationNetworkSensorEntityDescription + coordinator: PlayStationNetworkBaseCoordinator + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + if self.entity_description.key is PlaystationNetworkSensor.ONLINE_ID and ( + profile_pictures := self.coordinator.data.profile.get( + "personalDetail", {} + ).get("profilePictures") + ): + return next( + (pic.get("url") for pic in profile_pictures if pic.get("size") == "xl"), + None, + ) + return super().entity_picture + + @property + def available(self) -> bool: + """Return True if entity is available.""" + + return super().available and self.entity_description.available_fn( + self.coordinator.data + ) + + +class PlaystationNetworkSensorEntity(PlaystationNetworkSensorBaseEntity): + """Representation of a PlayStation Network sensor entity.""" + + coordinator: PlaystationNetworkUserDataCoordinator + + +class PlaystationNetworkFriendSensorEntity(PlaystationNetworkSensorBaseEntity): + """Representation of a PlayStation Network sensor entity.""" + + coordinator: PlaystationNetworkFriendDataCoordinator diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 19d61859f97..15b83b7cd0d 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -19,6 +19,16 @@ "data_description": { "npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]" } + }, + "reconfigure": { + "title": "Update PlayStation Network configuration", + "description": "[%key:component::playstation_network::config::step::user::description%]", + "data": { + "npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]" + }, + "data_description": { + "npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]" + } } }, "error": { @@ -29,8 +39,39 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_configured_as_subentry": "Already configured as a friend for another account. Delete the existing entry first.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**" + "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "config_subentries": { + "friend": { + "step": { + "user": { + "title": "Friend online status", + "description": "Track the online status of a PlayStation Network friend.", + "data": { + "account_id": "Online ID" + }, + "data_description": { + "account_id": "Select a friend from your friend list to track their online status." + } + } + }, + "initiate_flow": { + "user": "Add friend" + }, + "entry_type": "Friend", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured_as_entry": "Already configured as a service. This account cannot be added as a friend.", + "already_configured": "Already configured as a friend in this or another account.", + "no_friends": "Looks like your friend list is empty right now. Add friends on PlayStation Network first." + } } }, "exceptions": { @@ -39,6 +80,89 @@ }, "update_failed": { "message": "Data retrieval failed when trying to access the PlayStation Network." + }, + "group_invalid": { + "message": "Failed to send message to group {group_name}. The group is invalid or does not exist." + }, + "send_message_forbidden": { + "message": "Failed to send message to group {group_name}. You are not allowed to send messages to this group." + }, + "send_message_failed": { + "message": "Failed to send message to group {group_name}. Try again later." + }, + "user_profile_private": { + "message": "Unable to retrieve data for {user}. Privacy settings restrict access to activity." + }, + "user_not_found": { + "message": "Unable to retrieve data for {user}. User does not exist or has been removed." + } + }, + "entity": { + "binary_sensor": { + "ps_plus_status": { + "name": "Subscribed to PlayStation Plus" + } + }, + "sensor": { + "trophy_level": { + "name": "Trophy level" + }, + "trophy_level_progress": { + "name": "Next level" + }, + "earned_trophies_platinum": { + "name": "Platinum trophies", + "unit_of_measurement": "trophies" + }, + "earned_trophies_gold": { + "name": "Gold trophies", + "unit_of_measurement": "[%key:component::playstation_network::entity::sensor::earned_trophies_platinum::unit_of_measurement%]" + }, + "earned_trophies_silver": { + "name": "Silver trophies", + "unit_of_measurement": "[%key:component::playstation_network::entity::sensor::earned_trophies_platinum::unit_of_measurement%]" + }, + "earned_trophies_bronze": { + "name": "Bronze trophies", + "unit_of_measurement": "[%key:component::playstation_network::entity::sensor::earned_trophies_platinum::unit_of_measurement%]" + }, + "online_id": { + "name": "Online ID" + }, + "last_online": { + "name": "Last online" + }, + "online_status": { + "name": "Online status", + "state": { + "offline": "Offline", + "availabletoplay": "Online", + "availabletocommunicate": "Online on PS App", + "busy": "Away" + } + }, + "now_playing": { + "name": "Now playing" + } + }, + "image": { + "share_profile": { + "name": "Share profile" + }, + "avatar": { + "name": "Avatar" + }, + "now_playing_image": { + "name": "[%key:component::playstation_network::entity::sensor::now_playing::name%]" + } + }, + "notify": { + "group_message": { + "name": "Group: {group_name}" + }, + "direct_message": { + "name": "Direct message" + } } } } diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index e97493a78a7..f71d91d5bd1 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -27,10 +27,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> config_entry_id=entry.entry_id, identifiers={(DOMAIN, str(coordinator.api.gateway_id))}, manufacturer="Plugwise", - model=coordinator.api.smile_model, - model_id=coordinator.api.smile_model_id, - name=coordinator.api.smile_name, - sw_version=str(coordinator.api.smile_version), + model=coordinator.api.smile.model, + model_id=coordinator.api.smile.model_id, + name=coordinator.api.smile.name, + sw_version=str(coordinator.api.smile.version), ) # required for adding the entity-less P1 Gateway await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 834ff8bce76..22f204444d5 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -39,7 +39,7 @@ async def async_setup_entry( if not coordinator.new_devices: return - if coordinator.api.smile_name == "Adam": + if coordinator.api.smile.name == "Adam": async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices @@ -85,7 +85,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE if ( self.coordinator.api.cooling_present - and coordinator.api.smile_name != "Adam" + and coordinator.api.smile.name != "Adam" ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -165,7 +165,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if "regulation_modes" in self._gateway_data: hvac_modes.append(HVACMode.OFF) - if "available_schedules" in self.device: + if self.device.get("available_schedules"): hvac_modes.append(HVACMode.AUTO) if self.coordinator.api.cooling_present: diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index bf33d4c4a0f..a506969a109 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -204,11 +204,11 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): api, errors = await verify_connection(self.hass, user_input) if api: await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, + api.smile.hostname or api.gateway_id, raise_on_progress=False, ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=api.smile_name, data=user_input) + return self.async_create_entry(title=api.smile.name, data=user_input) return self.async_show_form( step_id=SOURCE_USER, @@ -236,7 +236,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): api, errors = await verify_connection(self.hass, full_input) if api: await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, + api.smile.hostname or api.gateway_id, raise_on_progress=False, ) self._abort_if_unique_id_mismatch(reason="not_the_same_smile") diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 39838c38fde..41e08a2b012 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -48,7 +48,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): manufacturer=data.get("vendor"), model=data.get("model"), model_id=data.get("model_id"), - name=coordinator.api.smile_name, + name=coordinator.api.smile.name, sw_version=data.get("firmware"), hw_version=data.get("hardware"), ) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 264afd79ed2..69b456ca8d8 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.7.4"], + "requirements": ["plugwise==1.7.8"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 6ca1d4ce7a2..6fc8f1615a7 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -70,7 +70,7 @@ async def async_setup_entry( PlugwiseSelectEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in SELECT_TYPES - if description.options_key in coordinator.data[device_id] + if coordinator.data[device_id].get(description.options_key) ) _add_entities() @@ -98,7 +98,7 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): self._location = location @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self.device[self.entity_description.key] diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 39af7867e97..b6718d7fd2d 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -28,8 +28,9 @@ class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, identifiers={(DOMAIN, device["device_id"])}, manufacturer="Minut", - model=f"Point v{device['hardware_version']}", + model="Point", name=device["description"], + hw_version=device["hardware_version"], sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py index 8e51985211d..c2f6830692c 100644 --- a/homeassistant/components/powerfox/__init__.py +++ b/homeassistant/components/powerfox/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from powerfox import Powerfox, PowerfoxConnectionError +from powerfox import DeviceType, Powerfox, PowerfoxConnectionError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant @@ -31,7 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> raise ConfigEntryNotReady from err coordinators: list[PowerfoxDataUpdateCoordinator] = [ - PowerfoxDataUpdateCoordinator(hass, entry, client, device) for device in devices + PowerfoxDataUpdateCoordinator(hass, entry, client, device) + for device in devices + # Filter out gas meter devices (Powerfox FLOW adapters) as they are not yet supported and cause integration failures + if device.type != DeviceType.GAS_METER ] await asyncio.gather( diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index f1e1839b735..439e44faad1 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1"] + "requirements": ["bluetooth-data-tools==1.28.2"] } diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 8818eff2d81..826d5872d7c 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -30,9 +30,9 @@ async def validate_input(hass: HomeAssistant, data): return { "title": is_valid["title"], - "relay_count": is_valid["relay_count"], - "input_count": is_valid["input_count"], - "is_old": is_valid["is_old"], + "relay_count": is_valid["relays"], + "input_count": is_valid["inputs"], + "is_old": is_valid["temps"], } diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 3adc33e9935..ac0e8f249f5 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -366,6 +366,7 @@ class PrometheusMetrics: @staticmethod def _sanitize_metric_name(metric: str) -> str: + metric.replace("\u03bc", "\u00b5") return "".join( [c if c in ALLOWED_METRIC_CHARS else f"u{hex(ord(c))}" for c in metric] ) @@ -747,6 +748,9 @@ class PrometheusMetrics: PERCENTAGE: "percent", } default = unit.replace("/", "_per_") + # Unit conversion for CONCENTRATION_MICROGRAMS_PER_CUBIC_METER "μg/m³" + # "μ" == "\u03bc" but the API uses "\u00b5" + default = default.replace("\u03bc", "\u00b5") default = default.lower() return units.get(unit, default) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 2338464558d..4dc87554055 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -43,17 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: ProximityConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index 5818ec2979b..f60dcfae7b5 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ZONE, UnitOfLength from homeassistant.core import State, callback @@ -87,7 +87,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> ProximityOptionsFlow: """Get the options flow for this handler.""" return ProximityOptionsFlow() @@ -118,7 +118,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): ) -class ProximityOptionsFlow(OptionsFlow): +class ProximityOptionsFlow(OptionsFlowWithReload): """Handle a option flow.""" def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index 5f713174f50..fa3be70f247 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -1,13 +1,13 @@ { "title": "Proximity", "config": { - "flow_title": "Proximity", + "flow_title": "[%key:component::proximity::title%]", "step": { "user": { "data": { "zone": "Zone to track distance to", "ignored_zones": "Zones to ignore", - "tracked_entities": "Devices or Persons to track", + "tracked_entities": "Devices or persons to track", "tolerance": "Tolerance distance" } } @@ -21,10 +21,10 @@ "step": { "init": { "data": { - "zone": "Zone to track distance to", - "ignored_zones": "Zones to ignore", - "tracked_entities": "Devices or Persons to track", - "tolerance": "Tolerance distance" + "zone": "[%key:component::proximity::config::step::user::data::zone%]", + "ignored_zones": "[%key:component::proximity::config::step::user::data::ignored_zones%]", + "tracked_entities": "[%key:component::proximity::config::step::user::data::tracked_entities%]", + "tolerance": "[%key:component::proximity::config::step::user::data::tolerance%]" } } } @@ -32,7 +32,7 @@ "entity": { "sensor": { "dir_of_travel": { - "name": "{tracked_entity} Direction of travel", + "name": "{tracked_entity} direction of travel", "state": { "arrived": "Arrived", "away_from": "Away from", @@ -40,15 +40,15 @@ "towards": "Towards" } }, - "dist_to_zone": { "name": "{tracked_entity} Distance" }, + "dist_to_zone": { "name": "{tracked_entity} distance" }, "nearest": { "name": "Nearest device" }, "nearest_dir_of_travel": { "name": "Nearest direction of travel", "state": { - "arrived": "Arrived", - "away_from": "Away from", - "stationary": "Stationary", - "towards": "Towards" + "arrived": "[%key:component::proximity::entity::sensor::dir_of_travel::state::arrived%]", + "away_from": "[%key:component::proximity::entity::sensor::dir_of_travel::state::away_from%]", + "stationary": "[%key:component::proximity::entity::sensor::dir_of_travel::state::stationary%]", + "towards": "[%key:component::proximity::entity::sensor::dir_of_travel::state::towards%]" } }, "nearest_dist_to_zone": { "name": "Nearest distance" } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 02074a18b61..af68aa446f5 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==11.2.1"] + "requirements": ["Pillow==11.3.0"] } diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index aaec7cdf105..ea866aa3942 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -191,7 +191,7 @@ class PS4Device(MediaPlayerEntity): ) elif self.state != MediaPlayerState.IDLE: self.idle() - elif self.state != MediaPlayerState.STANDBY: + elif self.state != MediaPlayerState.OFF: self.state_standby() elif self._retry > DEFAULT_RETRIES: @@ -223,7 +223,7 @@ class PS4Device(MediaPlayerEntity): def state_standby(self) -> None: """Set states for state standby.""" self.reset_title() - self._attr_state = MediaPlayerState.STANDBY + self._attr_state = MediaPlayerState.OFF def state_unknown(self) -> None: """Set states for state unknown.""" diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 78986b34351..0b7acdb1eb0 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -20,16 +20,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: - """Reload config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: """Unload config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 3ca7870b3cb..29139872913 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -312,7 +312,7 @@ class PurpleAirConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_by_coordinates() -class PurpleAirOptionsFlowHandler(OptionsFlow): +class PurpleAirOptionsFlowHandler(OptionsFlowWithReload): """Handle a PurpleAir options flow.""" def __init__(self) -> None: diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index a85a23b6144..3a2e42e63cb 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -132,7 +132,7 @@ SENSOR_DESCRIPTIONS = [ entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda sensor: sensor.pressure, + value_fn=lambda sensor: sensor.rssi, ), PurpleAirSensorEntityDescription( key="temperature", diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 2dbaa8fc713..ea9a8f198ef 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -116,7 +116,12 @@ class PushBulletNotificationSensor(SensorEntity): attributes into self._state_attributes. """ try: - self._attr_native_value = self.pb_provider.data[self.entity_description.key] + value = self.pb_provider.data[self.entity_description.key] + # Truncate state value to MAX_LENGTH_STATE_STATE while preserving full content in attributes + if isinstance(value, str) and len(value) > MAX_LENGTH_STATE_STATE: + self._attr_native_value = value[: MAX_LENGTH_STATE_STATE - 3] + "..." + else: + self._attr_native_value = value self._attr_extra_state_attributes = self.pb_provider.data except (KeyError, TypeError): pass diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 4d120e9fae7..ad35e409627 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -1,18 +1,17 @@ """The pvpc_hourly_pricing integration to collect Spain official electric prices.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN -from .coordinator import ElecPricesDataUpdateCoordinator +from .const import ATTR_POWER, ATTR_POWER_P3 +from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry from .helpers import get_enabled_sensor_keys PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool: """Set up pvpc hourly pricing from a config entry.""" entity_registry = er.async_get(hass) sensor_keys = get_enabled_sensor_keys( @@ -22,13 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = ElecPricesDataUpdateCoordinator(hass, entry, sensor_keys) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: PVPCConfigEntry) -> None: """Handle options update.""" if any( entry.data.get(attrib) != entry.options.get(attrib) @@ -41,9 +40,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> 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/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py index 28e676d37ed..bc9d6a21557 100644 --- a/homeassistant/components/pvpc_hourly_pricing/coordinator.py +++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py @@ -17,14 +17,16 @@ from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN _LOGGER = logging.getLogger(__name__) +type PVPCConfigEntry = ConfigEntry[ElecPricesDataUpdateCoordinator] + class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" - config_entry: ConfigEntry + config_entry: PVPCConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] + self, hass: HomeAssistant, entry: PVPCConfigEntry, sensor_keys: set[str] ) -> None: """Initialize.""" self.api = PVPCData( diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 1b92cfc533d..c49756290ab 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,7 +23,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ElecPricesDataUpdateCoordinator +from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry from .helpers import make_sensor_unique_id _LOGGER = logging.getLogger(__name__) @@ -149,11 +148,11 @@ _PRICE_SENSOR_ATTRIBUTES_MAP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PVPCConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the electricity price sensor from config_entry.""" - coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors = [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)] if coordinator.api.using_private_api: sensors.extend( diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 50d354d345d..1a1481f9c26 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -97,6 +98,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + _hassio_discovery: HassioServiceInfo | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -211,3 +214,58 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for pyLoad add-on. + + This flow is triggered by the discovery component. + """ + url = URL(discovery_info.config[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + discovery_info.config[CONF_URL] = url + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery.""" + assert self._hassio_discovery + errors: dict[str, str] = {} + + data = {**self._hassio_discovery.config, CONF_VERIFY_SSL: False} + + if user_input is not None: + data.update(user_input) + + try: + await validate_input(self.hass, data) + except (CannotConnect, ParserError): + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders=self._hassio_discovery.config, + ) + return self.async_create_entry(title=self._hassio_discovery.slug, data=data) + + return self.async_show_form( + step_id="hassio_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=REAUTH_SCHEMA, suggested_values=data + ), + description_placeholders=self._hassio_discovery.config, + errors=errors if user_input is not None else None, + ) diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 9414f7f7bb8..66435fd2806 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -39,6 +39,18 @@ "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]" } + }, + "hassio_confirm": { + "title": "pyLoad via Home Assistant add-on", + "description": "Do you want to configure Home Assistant to connect to the pyLoad service provided by the add-on: {addon}?", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" + } } }, "error": { diff --git a/homeassistant/components/qbus/binary_sensor.py b/homeassistant/components/qbus/binary_sensor.py new file mode 100644 index 00000000000..d91b6c9cbe6 --- /dev/null +++ b/homeassistant/components/qbus/binary_sensor.py @@ -0,0 +1,144 @@ +"""Support for Qbus binary sensor.""" + +from dataclasses import dataclass +from typing import cast + +from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.factory import QbusMqttTopicFactory +from qbusmqttapi.state import QbusMqttDeviceState, QbusMqttWeatherState + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import ( + QbusEntity, + create_device_identifier, + create_unique_id, + determine_new_outputs, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class QbusWeatherDescription(BinarySensorEntityDescription): + """Description for Qbus weather entities.""" + + property: str + + +_WEATHER_DESCRIPTIONS = ( + QbusWeatherDescription( + key="raining", + property="raining", + translation_key="raining", + ), + QbusWeatherDescription( + key="twilight", + property="twilight", + translation_key="twilight", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + added_controllers: list[str] = [] + + def _create_weather_entities() -> list[BinarySensorEntity]: + new_outputs = determine_new_outputs( + coordinator, added_outputs, lambda output: output.type == "weatherstation" + ) + + return [ + QbusWeatherBinarySensor(output, description) + for output in new_outputs + for description in _WEATHER_DESCRIPTIONS + ] + + def _create_controller_entities() -> list[BinarySensorEntity]: + if coordinator.data and coordinator.data.id not in added_controllers: + added_controllers.extend(coordinator.data.id) + return [QbusControllerConnectedBinarySensor(coordinator.data)] + + return [] + + def _check_outputs() -> None: + entities = [*_create_weather_entities(), *_create_controller_entities()] + async_add_entities(entities) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity): + """Representation of a Qbus weather binary sensor.""" + + _state_cls = QbusMqttWeatherState + + entity_description: QbusWeatherDescription + + def __init__( + self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription + ) -> None: + """Initialize binary sensor entity.""" + + super().__init__(mqtt_output, id_suffix=description.key) + + self.entity_description = description + + async def _handle_state_received(self, state: QbusMqttWeatherState) -> None: + if value := state.read_property(self.entity_description.property, None): + self._attr_is_on = ( + None if value is None else cast(str, value).lower() == "true" + ) + + +class QbusControllerConnectedBinarySensor(BinarySensorEntity): + """Representation of the Qbus controller connected sensor.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + def __init__(self, controller: QbusMqttDevice) -> None: + """Initialize binary sensor entity.""" + self._controller = controller + + self._attr_unique_id = create_unique_id(controller.serial_number, "connected") + self._attr_device_info = DeviceInfo( + identifiers={create_device_identifier(controller)} + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + topic = QbusMqttTopicFactory().get_device_state_topic(self._controller.id) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{topic}", + self._state_received, + ) + ) + + @callback + def _state_received(self, state: QbusMqttDeviceState) -> None: + self._attr_is_on = state.properties.connected if state.properties else None + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py index c6f234a14b7..a19ec4d0156 100644 --- a/homeassistant/components/qbus/climate.py +++ b/homeassistant/components/qbus/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.components.mqtt import client as mqtt from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -42,13 +42,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "thermo", QbusClimate, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) @@ -57,6 +57,8 @@ async def async_setup_entry( class QbusClimate(QbusEntity, ClimateEntity): """Representation of a Qbus climate entity.""" + _state_cls = QbusMqttThermoState + _attr_name = None _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ( @@ -128,14 +130,7 @@ class QbusClimate(QbusEntity, ClimateEntity): await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: - state = self._message_factory.parse_output_state( - QbusMqttThermoState, msg.payload - ) - - if state is None: - return - + async def _handle_state_received(self, state: QbusMqttThermoState) -> None: if preset_mode := state.read_regime(): self._attr_preset_mode = preset_mode @@ -155,8 +150,6 @@ class QbusClimate(QbusEntity, ClimateEntity): assert self._request_state_debouncer is not None await self._request_state_debouncer.async_call() - self.async_schedule_update_ha_state() - def _set_hvac_action(self) -> None: if self.target_temperature is None or self.current_temperature is None: self._attr_hvac_action = HVACAction.IDLE diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index e679c4b9927..3ecab64059a 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -6,9 +6,12 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.COVER, Platform.LIGHT, Platform.SCENE, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py index 42e226c8e6a..c3fbf4b60bb 100644 --- a/homeassistant/components/qbus/coordinator.py +++ b/homeassistant/components/qbus/coordinator.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import cast -from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from homeassistant.components.mqtt import ( @@ -19,6 +19,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.hass_dict import HassKey @@ -32,7 +33,7 @@ type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator] QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) -class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): +class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]): """Qbus data coordinator.""" _STATE_REQUEST_DELAY = 3 @@ -63,8 +64,8 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) ) - async def _async_update_data(self) -> list[QbusMqttOutput]: - return self._controller.outputs if self._controller else [] + async def _async_update_data(self) -> QbusMqttDevice | None: + return self._controller def shutdown(self, event: Event | None = None) -> None: """Shutdown Qbus coordinator.""" @@ -140,20 +141,25 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic ) - if self._controller is None or self._controller_activated: + if self._controller is None: return state = self._message_factory.parse_device_state(msg.payload) - if state and state.properties and state.properties.connectable is False: - _LOGGER.debug( - "%s - Activating controller %s", self.config_entry.unique_id, state.id - ) - self._controller_activated = True - request = self._message_factory.create_device_activate_request( - self._controller - ) - await mqtt.async_publish(self.hass, request.topic, request.payload) + if state and state.properties: + async_dispatcher_send(self.hass, f"{DOMAIN}_{msg.topic}", state) + + if not self._controller_activated and state.properties.connectable is False: + _LOGGER.debug( + "%s - Activating controller %s", + self.config_entry.unique_id, + state.id, + ) + self._controller_activated = True + request = self._message_factory.create_device_activate_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) def _request_entity_states(self) -> None: async def request_state(_: datetime) -> None: diff --git a/homeassistant/components/qbus/cover.py b/homeassistant/components/qbus/cover.py new file mode 100644 index 00000000000..3fc1b20602a --- /dev/null +++ b/homeassistant/components/qbus/cover.py @@ -0,0 +1,193 @@ +"""Support for Qbus cover.""" + +from typing import Any + +from qbusmqttapi.const import ( + KEY_PROPERTIES_SHUTTER_POSITION, + KEY_PROPERTIES_SLAT_POSITION, +) +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttShutterState, StateType + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, create_new_entities + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up cover entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + entities = create_new_entities( + coordinator, + added_outputs, + lambda output: output.type == "shutter", + QbusCover, + ) + async_add_entities(entities) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusCover(QbusEntity, CoverEntity): + """Representation of a Qbus cover entity.""" + + _state_cls = QbusMqttShutterState + + _attr_name = None + _attr_supported_features: CoverEntityFeature + _attr_device_class = CoverDeviceClass.BLIND + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize cover entity.""" + + super().__init__(mqtt_output) + + self._attr_assumed_state = False + self._attr_current_cover_position = 0 + self._attr_current_cover_tilt_position = 0 + self._attr_is_closed = True + + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + + if "shutterStop" in mqtt_output.actions: + self._attr_supported_features |= CoverEntityFeature.STOP + self._attr_assumed_state = True + + if KEY_PROPERTIES_SHUTTER_POSITION in mqtt_output.properties: + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + + if KEY_PROPERTIES_SLAT_POSITION in mqtt_output.properties: + self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._attr_supported_features |= CoverEntityFeature.OPEN_TILT + self._attr_supported_features |= CoverEntityFeature.CLOSE_TILT + + self._target_shutter_position: int | None = None + self._target_slat_position: int | None = None + self._target_state: str | None = None + self._previous_state: str | None = None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + + if self._attr_supported_features & CoverEntityFeature.SET_POSITION: + state.write_position(100) + else: + state.write_state("up") + + await self._async_publish_output_state(state) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + + if self._attr_supported_features & CoverEntityFeature.SET_POSITION: + state.write_position(0) + + if self._attr_supported_features & CoverEntityFeature.SET_TILT_POSITION: + state.write_slat_position(0) + else: + state.write_state("down") + + await self._async_publish_output_state(state) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_state("stop") + await self._async_publish_output_state(state) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_position(int(kwargs[ATTR_POSITION])) + await self._async_publish_output_state(state) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_slat_position(50) + await self._async_publish_output_state(state) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_slat_position(0) + await self._async_publish_output_state(state) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_slat_position(int(kwargs[ATTR_TILT_POSITION])) + await self._async_publish_output_state(state) + + async def _handle_state_received(self, state: QbusMqttShutterState) -> None: + output_state = state.read_state() + shutter_position = state.read_position() + slat_position = state.read_slat_position() + + if output_state is not None: + self._previous_state = self._target_state + self._target_state = output_state + + if shutter_position is not None: + self._target_shutter_position = shutter_position + + if slat_position is not None: + self._target_slat_position = slat_position + + self._update_is_closed() + self._update_cover_position() + self._update_tilt_position() + + def _update_is_closed(self) -> None: + if self._attr_supported_features & CoverEntityFeature.SET_POSITION: + if self._attr_supported_features & CoverEntityFeature.SET_TILT_POSITION: + self._attr_is_closed = ( + self._target_shutter_position == 0 + and self._target_slat_position in (0, 100) + ) + else: + self._attr_is_closed = self._target_shutter_position == 0 + else: + self._attr_is_closed = ( + self._previous_state == "down" and self._target_state == "stop" + ) + + def _update_cover_position(self) -> None: + self._attr_current_cover_position = ( + self._target_shutter_position + if self._attr_supported_features & CoverEntityFeature.SET_POSITION + else None + ) + + def _update_tilt_position(self) -> None: + self._attr_current_cover_tilt_position = ( + self._target_slat_position + if self._attr_supported_features & CoverEntityFeature.SET_TILT_POSITION + else None + ) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 70d469f9c93..f7205a85c00 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -5,67 +5,94 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable import re +from typing import Generic, TypeVar, cast -from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from qbusmqttapi.state import QbusMqttState from homeassistant.components.mqtt import ReceiveMessage, client as mqtt from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER from .coordinator import QbusControllerCoordinator _REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") +StateT = TypeVar("StateT", bound=QbusMqttState) -def add_new_outputs( + +def create_new_entities( coordinator: QbusControllerCoordinator, added_outputs: list[QbusMqttOutput], filter_fn: Callable[[QbusMqttOutput], bool], entity_type: type[QbusEntity], - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Call async_add_entities for new outputs.""" +) -> list[QbusEntity]: + """Create entities for new outputs.""" + + new_outputs = determine_new_outputs(coordinator, added_outputs, filter_fn) + return [entity_type(output) for output in new_outputs] + + +def determine_new_outputs( + coordinator: QbusControllerCoordinator, + added_outputs: list[QbusMqttOutput], + filter_fn: Callable[[QbusMqttOutput], bool], +) -> list[QbusMqttOutput]: + """Determine new outputs.""" added_ref_ids = {k.ref_id for k in added_outputs} - new_outputs = [ - output - for output in coordinator.data - if filter_fn(output) and output.ref_id not in added_ref_ids - ] + new_outputs = ( + [ + output + for output in coordinator.data.outputs + if filter_fn(output) and output.ref_id not in added_ref_ids + ] + if coordinator.data + else [] + ) if new_outputs: added_outputs.extend(new_outputs) - async_add_entities([entity_type(output) for output in new_outputs]) + + return new_outputs def format_ref_id(ref_id: str) -> str | None: """Format the Qbus ref_id.""" - matches: list[str] = re.findall(_REFID_REGEX, ref_id) - - if len(matches) > 0: - if ref_id := matches[0]: - return ref_id.replace("/", "-") + if match := _REFID_REGEX.search(ref_id): + return match.group(1).replace("/", "-") return None -def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str]: - """Create the identifier referring to the main device this output belongs to.""" - return (DOMAIN, format_mac(mqtt_output.device.mac)) +def create_device_identifier(mqtt_device: QbusMqttDevice) -> tuple[str, str]: + """Create the device identifier.""" + return (DOMAIN, format_mac(mqtt_device.mac)) -class QbusEntity(Entity, ABC): +def create_unique_id(serial_number: str, suffix: str) -> str: + """Create the unique id.""" + return f"ctd_{serial_number}_{suffix}" + + +class QbusEntity(Entity, Generic[StateT], ABC): """Representation of a Qbus entity.""" + _state_cls: type[StateT] = cast(type[StateT], QbusMqttState) + _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, mqtt_output: QbusMqttOutput) -> None: + def __init__( + self, + mqtt_output: QbusMqttOutput, + *, + id_suffix: str = "", + link_to_main_device: bool = False, + ) -> None: """Initialize the Qbus entity.""" self._mqtt_output = mqtt_output @@ -77,18 +104,28 @@ class QbusEntity(Entity, ABC): ) ref_id = format_ref_id(mqtt_output.ref_id) + suffix = ref_id or "" - self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + if id_suffix: + suffix += f"_{id_suffix}" - # Create linked device - self._attr_device_info = DeviceInfo( - name=mqtt_output.name.title(), - manufacturer=MANUFACTURER, - identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, - suggested_area=mqtt_output.location.title(), - via_device=create_main_device_identifier(mqtt_output), + self._attr_unique_id = create_unique_id( + mqtt_output.device.serial_number, suffix ) + if link_to_main_device: + self._attr_device_info = DeviceInfo( + identifiers={create_device_identifier(mqtt_output.device)} + ) + else: + self._attr_device_info = DeviceInfo( + name=mqtt_output.name.title(), + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, + suggested_area=mqtt_output.location.title(), + via_device=create_device_identifier(mqtt_output.device), + ) + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self.async_on_remove( @@ -97,9 +134,16 @@ class QbusEntity(Entity, ABC): ) ) - @abstractmethod async def _state_received(self, msg: ReceiveMessage) -> None: - pass + state = self._message_factory.parse_output_state(self._state_cls, msg.payload) + + if isinstance(state, self._state_cls): + await self._handle_state_received(state) + self.async_schedule_update_ha_state() + + @abstractmethod + async def _handle_state_received(self, state: StateT) -> None: + raise NotImplementedError async def _async_publish_output_state(self, state: QbusMqttState) -> None: request = self._message_factory.create_set_output_state_request( diff --git a/homeassistant/components/qbus/icons.json b/homeassistant/components/qbus/icons.json new file mode 100644 index 00000000000..400a2bba935 --- /dev/null +++ b/homeassistant/components/qbus/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "raining": { + "default": "mdi:weather-pouring" + }, + "twilight": { + "default": "mdi:weather-sunset" + } + } + } +} diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 654aab80ac7..61225f11243 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -6,13 +6,12 @@ from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import QbusMqttAnalogState, StateType from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.components.mqtt import ReceiveMessage from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -28,13 +27,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "analog", QbusLight, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) @@ -43,6 +42,8 @@ async def async_setup_entry( class QbusLight(QbusEntity, LightEntity): """Representation of a Qbus light entity.""" + _state_cls = QbusMqttAnalogState + _attr_name = None _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_color_mode = ColorMode.BRIGHTNESS @@ -57,17 +58,11 @@ class QbusLight(QbusEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - - percentage: int | None = None - on: bool | None = None - state = QbusMqttAnalogState(id=self._mqtt_output.id) if brightness is None: - on = True - state.type = StateType.ACTION - state.write_on_off(on) + state.write_on_off(on=True) else: percentage = round(brightness_to_value((1, 100), brightness)) @@ -83,16 +78,10 @@ class QbusLight(QbusEntity, LightEntity): await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: - output = self._message_factory.parse_output_state( - QbusMqttAnalogState, msg.payload - ) + async def _handle_state_received(self, state: QbusMqttAnalogState) -> None: + percentage = round(state.read_percentage()) + self._set_state(percentage) - if output is not None: - percentage = round(output.read_percentage()) - self._set_state(percentage) - self.async_schedule_update_ha_state() - - def _set_state(self, percentage: int = 0) -> None: + def _set_state(self, percentage: int) -> None: self._attr_is_on = percentage > 0 self._attr_brightness = value_to_brightness((1, 100), percentage) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index 17101da7c33..15392f6cc97 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -7,11 +7,12 @@ "documentation": "https://www.home-assistant.io/integrations/qbus", "integration_type": "hub", "iot_class": "local_push", + "loggers": ["qbusmqttapi"], "mqtt": [ "cloudapp/QBUSMQTTGW/state", "cloudapp/QBUSMQTTGW/config", "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.3.0"] + "requirements": ["qbusmqttapi==1.4.2"] } diff --git a/homeassistant/components/qbus/scene.py b/homeassistant/components/qbus/scene.py index 9a9a1e2df83..706fb089dde 100644 --- a/homeassistant/components/qbus/scene.py +++ b/homeassistant/components/qbus/scene.py @@ -5,14 +5,12 @@ from typing import Any from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import QbusMqttState, StateAction, StateType -from homeassistant.components.mqtt import ReceiveMessage from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs, create_main_device_identifier +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -28,13 +26,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "scene", QbusScene, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) @@ -46,12 +44,8 @@ class QbusScene(QbusEntity, Scene): def __init__(self, mqtt_output: QbusMqttOutput) -> None: """Initialize scene entity.""" - super().__init__(mqtt_output) + super().__init__(mqtt_output, link_to_main_device=True) - # Add to main controller device - self._attr_device_info = DeviceInfo( - identifiers={create_main_device_identifier(mqtt_output)} - ) self._attr_name = mqtt_output.name.title() async def async_activate(self, **kwargs: Any) -> None: @@ -61,6 +55,6 @@ class QbusScene(QbusEntity, Scene): ) await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: + async def _handle_state_received(self, state: QbusMqttState) -> None: # Nothing to do pass diff --git a/homeassistant/components/qbus/sensor.py b/homeassistant/components/qbus/sensor.py new file mode 100644 index 00000000000..e983e0a8cbb --- /dev/null +++ b/homeassistant/components/qbus/sensor.py @@ -0,0 +1,378 @@ +"""Support for Qbus sensor.""" + +from dataclasses import dataclass + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import ( + GaugeStateProperty, + QbusMqttGaugeState, + QbusMqttHumidityState, + QbusMqttThermoState, + QbusMqttVentilationState, + QbusMqttWeatherState, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSoundPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, create_new_entities, determine_new_outputs + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class QbusWeatherDescription(SensorEntityDescription): + """Description for Qbus weather entities.""" + + property: str + + +_WEATHER_DESCRIPTIONS = ( + QbusWeatherDescription( + key="daylight", + property="dayLight", + translation_key="daylight", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light", + property="light", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light_east", + property="lightEast", + translation_key="light_east", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light_south", + property="lightSouth", + translation_key="light_south", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light_west", + property="lightWest", + translation_key="light_west", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="temperature", + property="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + QbusWeatherDescription( + key="wind", + property="wind", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + ), +) + +_GAUGE_VARIANT_DESCRIPTIONS = { + "AIRPRESSURE": SensorEntityDescription( + key="airpressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + ), + "AIRQUALITY": SensorEntityDescription( + key="airquality", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + "CURRENT": SensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + "ENERGY": SensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + ), + "GAS": SensorEntityDescription( + key="gas", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "GASFLOW": SensorEntityDescription( + key="gasflow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "HUMIDITY": SensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "LIGHT": SensorEntityDescription( + key="light", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), + "LOUDNESS": SensorEntityDescription( + key="loudness", + device_class=SensorDeviceClass.SOUND_PRESSURE, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + state_class=SensorStateClass.MEASUREMENT, + ), + "POWER": SensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + "PRESSURE": SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.KPA, + state_class=SensorStateClass.MEASUREMENT, + ), + "TEMPERATURE": SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + "VOLTAGE": SensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + "VOLUME": SensorEntityDescription( + key="volume", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), + "WATER": SensorEntityDescription( + key="water", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.TOTAL, + ), + "WATERFLOW": SensorEntityDescription( + key="waterflow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "WATERLEVEL": SensorEntityDescription( + key="waterlevel", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + ), + "WATERPRESSURE": SensorEntityDescription( + key="waterpressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + ), + "WIND": SensorEntityDescription( + key="wind", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), +} + + +def _is_gauge_with_variant(output: QbusMqttOutput) -> bool: + return ( + output.type == "gauge" + and isinstance(output.variant, str) + and _GAUGE_VARIANT_DESCRIPTIONS.get(output.variant.upper()) is not None + ) + + +def _is_ventilation_with_co2(output: QbusMqttOutput) -> bool: + return output.type == "ventilation" and output.properties.get("co2") is not None + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _create_weather_entities() -> list[QbusEntity]: + new_outputs = determine_new_outputs( + coordinator, added_outputs, lambda output: output.type == "weatherstation" + ) + + return [ + QbusWeatherSensor(output, description) + for output in new_outputs + for description in _WEATHER_DESCRIPTIONS + ] + + def _check_outputs() -> None: + entities: list[QbusEntity] = [ + *create_new_entities( + coordinator, + added_outputs, + _is_gauge_with_variant, + QbusGaugeVariantSensor, + ), + *create_new_entities( + coordinator, + added_outputs, + lambda output: output.type == "humidity", + QbusHumiditySensor, + ), + *create_new_entities( + coordinator, + added_outputs, + lambda output: output.type == "thermo", + QbusThermoSensor, + ), + *create_new_entities( + coordinator, + added_outputs, + _is_ventilation_with_co2, + QbusVentilationSensor, + ), + *_create_weather_entities(), + ] + + async_add_entities(entities) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusGaugeVariantSensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for gauges with variant.""" + + _state_cls = QbusMqttGaugeState + + _attr_name = None + _attr_suggested_display_precision = 2 + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize sensor entity.""" + + super().__init__(mqtt_output) + + variant = str(mqtt_output.variant) + self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()] + + async def _handle_state_received(self, state: QbusMqttGaugeState) -> None: + self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE) + + +class QbusHumiditySensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for humidity modules.""" + + _state_cls = QbusMqttHumidityState + + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_name = None + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + async def _handle_state_received(self, state: QbusMqttHumidityState) -> None: + self._attr_native_value = state.read_value() + + +class QbusThermoSensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for thermostats.""" + + _state_cls = QbusMqttThermoState + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + + async def _handle_state_received(self, state: QbusMqttThermoState) -> None: + self._attr_native_value = state.read_current_temperature() + + +class QbusVentilationSensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for ventilations.""" + + _state_cls = QbusMqttVentilationState + + _attr_device_class = SensorDeviceClass.CO2 + _attr_name = None + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 0 + + async def _handle_state_received(self, state: QbusMqttVentilationState) -> None: + self._attr_native_value = state.read_co2() + + +class QbusWeatherSensor(QbusEntity, SensorEntity): + """Representation of a Qbus weather sensor.""" + + _state_cls = QbusMqttWeatherState + + entity_description: QbusWeatherDescription + + def __init__( + self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription + ) -> None: + """Initialize sensor entity.""" + + super().__init__(mqtt_output, id_suffix=description.key) + + self.entity_description = description + + if description.key == "temperature": + self._attr_name = None + + async def _handle_state_received(self, state: QbusMqttWeatherState) -> None: + if value := state.read_property(self.entity_description.property, None): + self.native_value = value diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index f308c5b3519..87788787baa 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -16,6 +16,30 @@ "no_controller": "No controllers were found" } }, + "entity": { + "binary_sensor": { + "raining": { + "name": "Raining" + }, + "twilight": { + "name": "Twilight" + } + }, + "sensor": { + "daylight": { + "name": "Daylight" + }, + "light_east": { + "name": "Illuminance east" + }, + "light_south": { + "name": "Illuminance south" + }, + "light_west": { + "name": "Illuminance west" + } + } + }, "exceptions": { "invalid_preset": { "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}." diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index c0e2b112bc5..3c4d280fa30 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -5,13 +5,12 @@ from typing import Any from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import QbusMqttOnOffState, StateType -from homeassistant.components.mqtt import ReceiveMessage from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -27,13 +26,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "onoff", QbusSwitch, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) @@ -42,6 +41,8 @@ async def async_setup_entry( class QbusSwitch(QbusEntity, SwitchEntity): """Representation of a Qbus switch entity.""" + _state_cls = QbusMqttOnOffState + _attr_name = None _attr_device_class = SwitchDeviceClass.SWITCH @@ -66,11 +67,5 @@ class QbusSwitch(QbusEntity, SwitchEntity): await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: - output = self._message_factory.parse_output_state( - QbusMqttOnOffState, msg.payload - ) - - if output is not None: - self._attr_is_on = output.read_value() - self.async_schedule_update_ha_state() + async def _handle_state_received(self, state: QbusMqttOnOffState) -> None: + self._attr_is_on = state.read_value() diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 0d82443da11..1979be3e827 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to the QNAP device", - "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", + "description": "This sensor allows getting various statistics from your QNAP NAS.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index e29e95abc62..70926adb29b 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==11.2.1", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.3.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/rachio/coordinator.py b/homeassistant/components/rachio/coordinator.py index 62d42f2afda..6d482e9c900 100644 --- a/homeassistant/components/rachio/coordinator.py +++ b/homeassistant/components/rachio/coordinator.py @@ -44,7 +44,6 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): base_count: int, ) -> None: """Initialize the Rachio Update Coordinator.""" - self.hass = hass self.rachio = rachio self.base_station = base_station super().__init__( @@ -83,7 +82,6 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]] base_station, ) -> None: """Initialize a Rachio schedule coordinator.""" - self.hass = hass self.rachio = rachio self.base_station = base_station super().__init__( diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index f9cd751a81e..e986cc302ae 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -218,6 +218,9 @@ def _async_fix_device_id( for device_entry in device_entries: unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry + if unique_id.startswith(mac_address): + # Already in the correct format + continue if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 8055074f395..794afd2287b 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -8,6 +8,5 @@ CONF_SERIAL_NUMBER = "serial_number" CONF_IMPORTED_NAMES = "imported_names" ATTR_DURATION = "duration" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" TIMEOUT_SECONDS = 20 diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6f92b1bdb97..ca7dc18b8d8 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -80,8 +80,8 @@ "description": "Sets how long automatic irrigation is turned off.", "fields": { "config_entry_id": { - "name": "Rainbird Controller Configuration Entry", - "description": "The setting will be adjusted on the specified controller." + "name": "Rain Bird controller", + "description": "The configuration entry of the controller to adjust the setting." }, "duration": { "name": "Duration", diff --git a/homeassistant/components/rainmachine/entity.py b/homeassistant/components/rainmachine/entity.py index 1289d3e808e..441cf8237b6 100644 --- a/homeassistant/components/rainmachine/entity.py +++ b/homeassistant/components/rainmachine/entity.py @@ -56,11 +56,9 @@ class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)}, name=self._data.controller.name.capitalize(), manufacturer="RainMachine", - model=( - f"Version {self._version_coordinator.data['hwVer']} " - f"(API: {self._version_coordinator.data['apiVer']})" - ), - sw_version=self._version_coordinator.data["swVer"], + hw_version=self._version_coordinator.data["hwVer"], + sw_version=f"{self._version_coordinator.data['swVer']} " + f"(API: {self._version_coordinator.data['apiVer']})", ) @callback diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index aad61458e88..e8c54c94f84 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -196,12 +196,12 @@ "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." }, "mintemp": { - "name": "Min temp", - "description": "Minimum temperature (°C)." + "name": "Min temperature", + "description": "Minimum temperature in current period (°C)." }, "maxtemp": { - "name": "Max temp", - "description": "Maximum temperature (°C)." + "name": "Max temperature", + "description": "Maximum temperature in current period (°C)." }, "temperature": { "name": "Temperature", @@ -209,11 +209,11 @@ }, "wind": { "name": "Wind speed", - "description": "Wind speed (m/s)." + "description": "Current wind speed (m/s)." }, "solarrad": { "name": "Solar radiation", - "description": "Solar radiation (MJ/m²/h)." + "description": "Current solar radiation (MJ/m²/h)." }, "et": { "name": "Evapotranspiration", @@ -229,23 +229,23 @@ }, "minrh": { "name": "Min relative humidity", - "description": "Min relative humidity (%RH)." + "description": "Minimum relative humidity in current period (%RH)." }, "maxrh": { "name": "Max relative humidity", - "description": "Max relative humidity (%RH)." + "description": "Maximum relative humidity in current period (%RH)." }, "condition": { "name": "Weather condition code", "description": "Current weather condition code (WNUM)." }, "pressure": { - "name": "Barametric pressure", - "description": "Barametric pressure (kPa)." + "name": "Barometric pressure", + "description": "Current barometric pressure (kPa)." }, "dewpoint": { "name": "Dew point", - "description": "Dew point (°C)." + "description": "Current dew point (°C)." } } }, diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index d57f2dc8eec..450f78f9e83 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -82,6 +82,7 @@ }, "sensor_device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "area": "[%key:component::sensor::entity_component::area::name%]", @@ -129,7 +130,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index e14a165f81f..3952f76bddd 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -261,7 +261,7 @@ def correct_db_schema_precision( from ..migration import _modify_columns # noqa: PLC0415 precision_columns = _get_precision_column_types(table_object) - # Attempt to convert timestamp columns to µs precision + # Attempt to convert timestamp columns to μs precision session_maker = instance.get_session engine = instance.engine assert engine is not None, "Engine should be set" diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index d8d7ddb832a..2ee41ba2038 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -12,6 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.pool import ( ConnectionPoolEntry, NullPool, + PoolProxiedConnection, SingletonThreadPool, StaticPool, ) @@ -119,6 +120,12 @@ class RecorderPool(SingletonThreadPool, NullPool): ) return NullPool._create_connection(self) # noqa: SLF001 + def connect(self) -> PoolProxiedConnection: + """Return a connection from the pool.""" + if threading.get_ident() in self.recorder_and_worker_thread_ids: + return super().connect() + return NullPool.connect(self) + class MutexPool(StaticPool): """A pool which prevents concurrent accesses from multiple threads. diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7326519b14e..2321da45bb9 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -42,6 +42,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.collection import chunked_or_all from homeassistant.util.enum import try_parse_enum from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -59,6 +60,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -193,6 +195,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **dict.fromkeys(ApparentPowerConverter.VALID_UNITS, ApparentPowerConverter), **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter), **dict.fromkeys( BloodGlucoseConcentrationConverter.VALID_UNITS, @@ -214,6 +217,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter), **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter), **dict.fromkeys(ReactiveEnergyConverter.VALID_UNITS, ReactiveEnergyConverter), + **dict.fromkeys(ReactivePowerConverter.VALID_UNITS, ReactivePowerConverter), **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter), **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter), **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter), diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d052631c5f6..4f798fb86d0 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -32,6 +33,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -59,6 +61,7 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10 UNIT_SCHEMA = vol.Schema( { + vol.Optional("apparent_power"): vol.In(ApparentPowerConverter.VALID_UNITS), vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS), vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS @@ -79,6 +82,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS), + vol.Optional("reactive_power"): vol.In(ReactivePowerConverter.VALID_UNITS), vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index f6918ea9706..7009a8af360 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -98,7 +98,7 @@ def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" return CalendarEvent( - summary=event.summary, + summary=event.summary or "", start=( dt_util.as_local(event.start) if isinstance(event.start, datetime) diff --git a/homeassistant/components/remote_calendar/client.py b/homeassistant/components/remote_calendar/client.py new file mode 100644 index 00000000000..f0f243ca386 --- /dev/null +++ b/homeassistant/components/remote_calendar/client.py @@ -0,0 +1,12 @@ +"""Specifies the parameter for the httpx download.""" + +from httpx import AsyncClient, Response, Timeout + + +async def get_calendar(client: AsyncClient, url: str) -> Response: + """Make an HTTP GET request using Home Assistant's async HTTPX client with timeout.""" + return await client.get( + url, + 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 558a3d668ae..3f835b5d82b 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -4,13 +4,14 @@ from http import HTTPStatus import logging from typing import Any -from httpx import HTTPError, InvalidURL +from httpx import HTTPError, InvalidURL, TimeoutException import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.helpers.httpx_client import get_async_client +from .client import get_calendar from .const import CONF_CALENDAR_NAME, DOMAIN from .ics import InvalidIcsException, parse_calendar @@ -49,7 +50,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) client = get_async_client(self.hass) try: - res = await client.get(user_input[CONF_URL], follow_redirects=True) + res = await get_calendar(client, user_input[CONF_URL]) if res.status_code == HTTPStatus.FORBIDDEN: errors["base"] = "forbidden" return self.async_show_form( @@ -58,9 +59,14 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) 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", err) + _LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__) else: try: await parse_calendar(self.hass, res.text) diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 1eead7682d3..7a7abe37b89 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from httpx import HTTPError, InvalidURL +from httpx import HTTPError, InvalidURL, TimeoutException from ical.calendar import Calendar from homeassistant.config_entries import ConfigEntry @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .client import get_calendar from .const import DOMAIN from .ics import InvalidIcsException, parse_calendar @@ -36,8 +37,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): super().__init__( hass, _LOGGER, - name=DOMAIN, + name=f"{DOMAIN}_{config_entry.title}", update_interval=SCAN_INTERVAL, + config_entry=config_entry, always_update=True, ) self._client = get_async_client(hass) @@ -46,13 +48,19 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): async def _async_update_data(self) -> Calendar: """Update data from the url.""" try: - res = await self._client.get(self._url, follow_redirects=True) + res = await get_calendar(self._client, self._url) res.raise_for_status() + except TimeoutException as err: + _LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout", + ) from err except (HTTPError, InvalidURL) as err: + _LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__) raise UpdateFailed( translation_domain=DOMAIN, translation_key="unable_to_fetch", - translation_placeholders={"err": str(err)}, ) from err try: self.ics = res.text diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 6ba1dea55ed..b4e2d186add 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==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index ef7f20d4699..48ef6080bdb 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -18,14 +18,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "forbidden": "The server understood the request but refuses to authorize it.", "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "exceptions": { + "timeout": { + "message": "The connection timed out. See the debug log for additional details." + }, "unable_to_fetch": { - "message": "Unable to fetch calendar data: {err}" + "message": "Unable to fetch calendar data. See the debug log for additional details." }, "unable_to_parse": { "message": "Unable to parse calendar data: {err}" diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 48bab1f5c8b..da3769654c4 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type RenaultConfigEntry = ConfigEntry[RenaultHub] @@ -20,7 +20,7 @@ type RenaultConfigEntry = ConfigEntry[RenaultHub] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Renault component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 2861c52c24a..9fe01c5b952 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.3.1"] + "requirements": ["renault-api==0.4.0"] } diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 1f883435dee..5e14328eb7c 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -156,6 +156,7 @@ class RenaultHub: name=vehicle.device_info[ATTR_NAME], model=vehicle.device_info[ATTR_MODEL], model_id=vehicle.device_info[ATTR_MODEL_ID], + sw_version=None, # cleanup from PR #125399 ) self._vehicles[vehicle_link.vin] = vehicle diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index dfad97ae4ea..df85ad57f66 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -191,7 +191,8 @@ def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy: ) -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register the Renault services.""" hass.services.async_register( diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 474ab640943..c82cad012c3 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -196,7 +196,7 @@ class RensonFan(RensonEntity, FanEntity): all_data = self.coordinator.data breeze_temp = self.api.get_field_value(all_data, BREEZE_TEMPERATURE_FIELD) await self.hass.async_add_executor_job( - self.api.set_breeze, cmd.name, breeze_temp, True + self.api.set_breeze, cmd, breeze_temp, True ) else: await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 3260bff44b5..42a29ee6ef4 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -59,7 +59,7 @@ PLATFORMS = [ Platform.UPDATE, ] DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) -FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24) NUM_CRED_ERRORS = 3 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -243,10 +243,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload( - config_entry.add_update_listener(entry_update_listener) - ) - return True @@ -295,13 +291,6 @@ async def register_callbacks( ) -async def entry_update_listener( - hass: HomeAssistant, config_entry: ReolinkConfigEntry -) -> None: - """Update the configuration of the host entity.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index eee8b04dfcc..2ac51792c3f 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -61,7 +61,7 @@ DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} API_STARTUP_TIME = 5 -class ReolinkOptionsFlowHandler(OptionsFlow): +class ReolinkOptionsFlowHandler(OptionsFlowWithReload): """Handle Reolink options.""" async def async_step_init( diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index c5085c9ca18..912427fa881 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -24,6 +24,8 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch) IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + if (signal := api.wifi_signal(ch)) is not None and api.wifi_connection(ch): + IPC_cam[ch]["WiFi signal"] = signal chimes: dict[int, dict[str, Any]] = {} for chime in api.chime_list: @@ -41,8 +43,8 @@ async def async_get_config_entry_diagnostics( "HTTP(S) port": api.port, "Baichuan port": api.baichuan.port, "Baichuan only": api.baichuan_only, - "WiFi connection": api.wifi_connection, - "WiFi signal": api.wifi_signal, + "WiFi connection": api.wifi_connection(), + "WiFi signal": api.wifi_signal(), "RTMP enabled": api.rtmp_enabled, "RTSP enabled": api.rtsp_enabled, "ONVIF enabled": api.onvif_enabled, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index a83dc259e1b..971b7ec4be1 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -167,7 +167,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data, coordinator) self._channel = channel - if self._host.api.supported(channel, "UID"): + if self._host.api.is_nvr and self._host.api.supported(channel, "UID"): self._attr_unique_id = f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}_{self.entity_description.key}" else: self._attr_unique_id = ( diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index cf3079e51e8..597a3372400 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -300,6 +300,12 @@ }, "image_hue": { "default": "mdi:image-edit" + }, + "pre_record_time": { + "default": "mdi:history" + }, + "pre_record_battery_stop": { + "default": "mdi:history" } }, "select": { @@ -389,6 +395,12 @@ }, "packing_time": { "default": "mdi:record-rec" + }, + "pre_record_fps": { + "default": "mdi:history" + }, + "post_rec_time": { + "default": "mdi:record-rec" } }, "sensor": { @@ -402,7 +414,12 @@ "default": "mdi:thermometer" }, "battery_state": { - "default": "mdi:battery-charging" + "default": "mdi:battery-unknown", + "state": { + "discharging": "mdi:battery-minus-variant", + "charging": "mdi:battery-charging", + "chargecomplete": "mdi:battery-check" + } }, "day_night_state": { "default": "mdi:theme-light-dark" @@ -462,6 +479,9 @@ "manual_record": { "default": "mdi:record-rec" }, + "pre_record": { + "default": "mdi:history" + }, "hub_ringtone_on_event": { "default": "mdi:music-note" }, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 04996689bf7..4ad80dda807 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.1"] + "requirements": ["reolink-aio==0.14.6"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 9c8c685d898..f716340e06e 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -422,9 +422,7 @@ class ReolinkVODMediaSource(MediaSource): file_name = f"{file.start_time.time()} {file.duration}" if file.triggers != file.triggers.NONE: file_name += " " + " ".join( - str(trigger.name).title() - for trigger in file.triggers - if trigger != trigger.NONE + str(trigger.name).title() for trigger in file.triggers ) children.append( diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 2de2468ca3d..da879194e88 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -116,6 +116,7 @@ NUMBER_ENTITIES = ( cmd_id=[289, 438], translation_key="floodlight_brightness", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=1, native_max_value=100, @@ -407,8 +408,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_left", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -420,8 +421,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_right", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -435,6 +436,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_disappear_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -451,6 +453,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_stop_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -542,6 +545,38 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.image_hue(ch), method=lambda api, ch, value: api.set_image(ch, hue=int(value)), ), + ReolinkNumberEntityDescription( + key="pre_record_time", + cmd_key="594", + translation_key="pre_record_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=2, + native_max_value=10, + native_unit_of_measurement=UnitOfTime.SECONDS, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_time(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, time=int(value) + ), + ), + ReolinkNumberEntityDescription( + key="pre_record_battery_stop", + cmd_key="594", + translation_key="pre_record_battery_stop", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=10, + native_max_value=80, + native_unit_of_measurement=PERCENTAGE, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_battery_stop(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, battery_stop=int(value) + ), + ), ) SMART_AI_NUMBER_ENTITIES = ( diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 2ee2b790687..242ea784cd9 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -250,6 +250,31 @@ SELECT_ENTITIES = ( value=lambda api, ch: str(api.bit_rate(ch, "sub")), method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"), ), + ReolinkSelectEntityDescription( + key="pre_record_fps", + cmd_key="594", + translation_key="pre_record_fps", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + unit_of_measurement=UnitOfFrequency.HERTZ, + get_options=["1", "2", "5"], + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: str(api.baichuan.pre_record_fps(ch)), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, fps=int(value) + ), + ), + ReolinkSelectEntityDescription( + key="post_rec_time", + cmd_key="GetRec", + translation_key="post_rec_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + get_options=lambda api, ch: api.post_recording_time_list(ch), + supported=lambda api, ch: api.supported(ch, "post_rec_time"), + value=lambda api, ch: api.post_recording_time(ch), + method=lambda api, ch, value: api.set_post_recording_time(ch, value), + ), ) HOST_SELECT_ENTITIES = ( diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 85de03dd1a3..9b9a78c8ce7 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -16,7 +16,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -118,18 +123,32 @@ SENSORS = ( value=lambda api, ch: api.baichuan.day_night_state(ch), supported=lambda api, ch: api.supported(ch, "day_night_state"), ), + ReolinkSensorEntityDescription( + key="wifi_signal", + cmd_key="115", + translation_key="wifi_signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + value=lambda api, ch: api.wifi_signal(ch), + supported=lambda api, ch: api.supported(ch, "wifi"), + ), ) HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", - cmd_key="GetWifiSignal", + cmd_key="115", translation_key="wifi_signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, - value=lambda api: api.wifi_signal, - supported=lambda api: api.supported(None, "wifi") and api.wifi_connection, + value=lambda api: api.wifi_signal(), + supported=lambda api: api.supported(None, "wifi") and api.wifi_connection(), ), ReolinkHostSensorEntityDescription( key="cpu_usage", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5473887a8ff..7e8bf94eeae 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -654,6 +654,12 @@ }, "image_hue": { "name": "Image hue" + }, + "pre_record_time": { + "name": "Pre-recording time" + }, + "pre_record_battery_stop": { + "name": "Pre-recording stop battery level" } }, "select": { @@ -857,6 +863,12 @@ }, "packing_time": { "name": "Recording packing time" + }, + "pre_record_fps": { + "name": "Pre-recording frame rate" + }, + "post_rec_time": { + "name": "Post-recording time" } }, "sensor": { @@ -943,6 +955,9 @@ "manual_record": { "name": "Manual record" }, + "pre_record": { + "name": "Pre-recording" + }, "hub_ringtone_on_event": { "name": "Hub ringtone on event" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 47b14f7f4ad..00934bc9777 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -169,6 +169,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.manual_record_enabled(ch), method=lambda api, ch, value: api.set_manual_record(ch, value), ), + ReolinkSwitchEntityDescription( + key="pre_record", + cmd_key="594", + translation_key="pre_record", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_enabled(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording(ch, enabled=value), + ), ReolinkSwitchEntityDescription( key="buzzer", cmd_key="GetBuzzerAlarmV20", diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index cc7e017699d..63da15b1ede 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -89,8 +89,6 @@ class RepairsFlowManager(data_entry_flow.FlowManager): """ if result.get("type") != data_entry_flow.FlowResultType.ABORT: ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"]) - if "result" not in result: - result["result"] = None return result diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 4117b0ee35b..d09c567bb71 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -137,9 +137,9 @@ class RepairsFlowIndexView(FlowManagerIndexView): "Handler does not support user", HTTPStatus.BAD_REQUEST ) - result = self._prepare_result_json(result) - - return self.json(result) + return self.json( + self._prepare_result_json(result), + ) class RepairsFlowResourceView(FlowManagerResourceView): diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 3c02f62f852..2964ef73d46 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -6,6 +6,7 @@ import logging from typing import Any import aiohttp +from aiohttp import hdrs from multidict import CIMultiDictProxy import xmltodict @@ -44,11 +45,12 @@ class RestData: self._method = method self._resource = resource self._encoding = encoding + self._force_use_set_encoding = False # Convert auth tuple to aiohttp.BasicAuth if needed if isinstance(auth, tuple) and len(auth) == 2: self._auth: aiohttp.BasicAuth | aiohttp.DigestAuthMiddleware | None = ( - aiohttp.BasicAuth(auth[0], auth[1]) + aiohttp.BasicAuth(auth[0], auth[1], encoding="utf-8") ) else: self._auth = auth @@ -77,6 +79,12 @@ class RestData: """Set url.""" self._resource = url + def _is_expected_content_type(self, content_type: str) -> bool: + """Check if the content type is one we expect (JSON or XML).""" + return content_type.startswith( + ("application/json", "text/json", *XML_MIME_TYPES) + ) + def data_without_xml(self) -> str | None: """If the data is an XML string, convert it to a JSON string.""" _LOGGER.debug("Data fetched from resource: %s", self.data) @@ -84,7 +92,7 @@ class RestData: (value := self.data) is not None # If the http request failed, headers will be None and (headers := self.headers) is not None - and (content_type := headers.get("content-type")) + and (content_type := headers.get(hdrs.CONTENT_TYPE)) and content_type.startswith(XML_MIME_TYPES) ): value = json_dumps(xmltodict.parse(value)) @@ -103,6 +111,22 @@ class RestData: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) + # Convert boolean values to lowercase strings for compatibility with aiohttp/yarl + if rendered_params: + for key, value in rendered_params.items(): + if isinstance(value, bool): + rendered_params[key] = str(value).lower() + elif not isinstance(value, (str, int, float, type(None))): + # For backward compatibility with httpx behavior, convert non-primitive + # types to strings. This maintains compatibility after switching from + # httpx to aiohttp. See https://github.com/home-assistant/core/issues/148153 + _LOGGER.debug( + "REST query parameter '%s' has type %s, converting to string", + key, + type(value).__name__, + ) + rendered_params[key] = str(value) + _LOGGER.debug("Updating from %s", self._resource) # Create request kwargs request_kwargs: dict[str, Any] = { @@ -120,13 +144,30 @@ class RestData: # Handle data/content if self._request_data: request_kwargs["data"] = self._request_data + response = None try: # Make the request async with self._session.request( self._method, self._resource, **request_kwargs ) as response: # Read the response - self.data = await response.text(encoding=self._encoding) + # Only use configured encoding if no charset in Content-Type header + # If charset is present in Content-Type, let aiohttp use it + if self._force_use_set_encoding is False and response.charset: + # Let aiohttp use the charset from Content-Type header + try: + self.data = await response.text() + except UnicodeDecodeError as ex: + self._force_use_set_encoding = True + _LOGGER.debug( + "Response charset came back as %s but could not be decoded, continue with configured encoding %s. %s", + response.charset, + self._encoding, + ex, + ) + if self._force_use_set_encoding or not response.charset: + # Use configured encoding as fallback + self.data = await response.text(encoding=self._encoding) self.headers = response.headers except TimeoutError as ex: @@ -143,3 +184,34 @@ class RestData: self.last_exception = ex self.data = None self.headers = None + + # Log response details outside the try block so we always get logging + if response is None: + return + + # Log response details for debugging + content_type = response.headers.get(hdrs.CONTENT_TYPE) + _LOGGER.debug( + "REST response from %s: status=%s, content-type=%s, length=%s", + self._resource, + response.status, + content_type or "not set", + len(self.data) if self.data else 0, + ) + + # If we got an error response with non-JSON/XML content, log a sample + # This helps debug issues like servers blocking with HTML error pages + if ( + response.status >= 400 + and content_type + and not self._is_expected_content_type(content_type) + ): + sample = self.data[:500] if self.data else "" + _LOGGER.warning( + "REST request to %s returned status %s with %s response: %s%s", + self._resource, + response.status, + content_type, + sample, + "..." if self.data and len(self.data) > 500 else "", + ) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 9df10197a1a..3db44b0e5d2 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -13,9 +13,7 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, ) -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, @@ -181,18 +179,6 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): self.entity_id, variables, None ) - if value is None or self.device_class not in ( - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - ): - self._attr_native_value = value - self._process_manual_data(variables) - self.async_write_ha_state() - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c6a4206de4a..0ea5fc60472 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -178,6 +178,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not service.return_response: + # always read the response to avoid closing the connection + # before the server has finished sending it, while avoiding excessive memory usage + async for _ in response.content.iter_chunked(1024): + pass + return None _content = None @@ -205,7 +210,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "decoding_type": "text", }, ) from err - return {"content": _content, "status": response.status} + return { + "content": _content, + "status": response.status, + "headers": dict(response.headers), + } except TimeoutError as err: raise HomeAssistantError( diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index af8d2c76844..7eb53433d88 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -221,8 +221,8 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity): elif command in ["off", "alloff"]: self._state = False # dimmable device accept 'set_level=(0-15)' commands - elif re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE): - self._brightness = rflink_to_brightness(int(command.split("=")[1])) + elif match := re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE): + self._brightness = rflink_to_brightness(int(match.group(1))) self._state = True @property diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 86758b26794..e7436e4d12d 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -29,5 +29,6 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], + "quality_scale": "bronze", "requirements": ["ring-doorbell==0.9.13"] } diff --git a/homeassistant/components/ring/quality_scale.yaml b/homeassistant/components/ring/quality_scale.yaml new file mode 100644 index 00000000000..64bc5c23c3f --- /dev/null +++ b/homeassistant/components/ring/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: + status: exempt + comment: The integration does not register services + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: The integration does not register custom service actions + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: todo + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have any options configuration parameters + + # Gold + entity-translations: + status: todo + comment: Use device class translations for volume sensor and number + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + dynamic-devices: todo + discovery-update-info: + status: exempt + comment: The integration uses ring cloud api to identify devices and \ + does not use network identifiers + repair-issues: done + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index 2472baa932e..f485c923776 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -82,7 +82,6 @@ async def async_setup_entry( class RiscoAlarm(AlarmControlPanelEntity): """Representation of a Risco cloud partition.""" - _attr_code_format = CodeFormat.NUMBER _attr_has_entity_name = True _attr_name = None @@ -100,8 +99,13 @@ class RiscoAlarm(AlarmControlPanelEntity): self._partition_id = partition_id self._partition = partition self._code = code - self._attr_code_arm_required = options[CONF_CODE_ARM_REQUIRED] - self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] + arm_required = options[CONF_CODE_ARM_REQUIRED] + disarm_required = options[CONF_CODE_DISARM_REQUIRED] + self._attr_code_arm_required = arm_required + self._code_disarm_required = disarm_required + self._attr_code_format = ( + CodeFormat.NUMBER if arm_required or disarm_required else None + ) self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] for state in self._ha_to_risco: diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 86d131b4f80..22ed3ff4e52 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -45,7 +45,7 @@ }, "risco_to_ha": { "title": "Map Risco states to Home Assistant states", - "description": "Select what state your Home Assistant alarm will report for every state reported by Risco", + "description": "Select what state your Home Assistant alarm control panel will report for every state reported by Risco", "data": { "arm": "Armed (AWAY)", "partial_arm": "Partially Armed (STAY)", @@ -57,12 +57,12 @@ }, "ha_to_risco": { "title": "Map Home Assistant states to Risco states", - "description": "Select what state to set your Risco alarm to when arming the Home Assistant alarm", + "description": "Select what state to set your Risco alarm to when arming the Home Assistant alarm control panel", "data": { - "armed_away": "Armed Away", - "armed_home": "Armed Home", - "armed_night": "Armed Night", - "armed_custom_bypass": "Armed Custom Bypass" + "armed_away": "[%key:component::alarm_control_panel::entity_component::_::state::armed_away%]", + "armed_home": "[%key:component::alarm_control_panel::entity_component::_::state::armed_home%]", + "armed_night": "[%key:component::alarm_control_panel::entity_component::_::state::armed_night%]", + "armed_custom_bypass": "[%key:component::alarm_control_panel::entity_component::_::state::armed_custom_bypass%]" } } } diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 6697779adf6..bc10ab7309c 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -43,8 +43,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Set up roborock from a config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) - user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) api_client = RoborockApiClient( entry.data[CONF_USERNAME], @@ -336,12 +334,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: - """Handle options update.""" - # Reload entry to update data - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: """Handle removal of an entry.""" await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 62943e0dcc9..6a35bf79233 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback @@ -124,14 +124,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() - self.hass.config_entries.async_update_entry( - reauth_entry, - data={ - **reauth_entry.data, - CONF_USER_DATA: user_data.as_dict(), - }, + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()} ) - return self.async_abort(reason="reauth_successful") self._abort_if_unique_id_configured(error="already_configured_account") return self._create_entry(self._client, self._username, user_data) @@ -202,7 +197,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): return RoborockOptionsFlowHandler(config_entry) -class RoborockOptionsFlowHandler(OptionsFlow): +class RoborockOptionsFlowHandler(OptionsFlowWithReload): """Handle an option flow for Roborock.""" def __init__(self, config_entry: RoborockConfigEntry) -> None: diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 058fffbdb1c..afdb3b19cb4 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -109,7 +109,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.CLEAN_SPOT @@ -142,11 +141,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): assert self._device_status.state is not None return STATE_CODE_TO_STATE.get(self._device_status.state) - @property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - return self._device_status.battery - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" @@ -154,10 +148,14 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): async def async_start(self) -> None: """Start the vacuum.""" - if self._device_status.in_cleaning == 2: + if self._device_status.in_returning == 1: + await self.send(RoborockCommand.APP_CHARGE) + elif self._device_status.in_cleaning == 2: await self.send(RoborockCommand.RESUME_ZONED_CLEAN) elif self._device_status.in_cleaning == 3: await self.send(RoborockCommand.RESUME_SEGMENT_CLEAN) + elif self._device_status.in_cleaning == 4: + await self.send(RoborockCommand.APP_RESUME_BUILD_MAP) else: await self.send(RoborockCommand.APP_START) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index be0b20c97fb..46149264e55 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -25,16 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True async def async_unload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 47bc86802d2..b28648589c9 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -202,7 +202,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return RokuOptionsFlowHandler() -class RokuOptionsFlowHandler(OptionsFlow): +class RokuOptionsFlowHandler(OptionsFlowWithReload): """Handle Roku options.""" async def async_step_init( diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index d0e1e3a53c0..7f815c4e458 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -142,7 +142,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): def state(self) -> MediaPlayerState | None: """Return the state of the device.""" if self.coordinator.data.state.standby: - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF if self.coordinator.data.app is None: return None @@ -308,21 +308,21 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): @roku_exception_handler() async def async_media_pause(self) -> None: """Send pause command.""" - if self.state not in {MediaPlayerState.STANDBY, MediaPlayerState.PAUSED}: + if self.state not in {MediaPlayerState.OFF, MediaPlayerState.PAUSED}: await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() @roku_exception_handler() async def async_media_play(self) -> None: """Send play command.""" - if self.state not in {MediaPlayerState.STANDBY, MediaPlayerState.PLAYING}: + if self.state not in {MediaPlayerState.OFF, MediaPlayerState.PLAYING}: await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() @roku_exception_handler() async def async_media_play_pause(self) -> None: """Send play/pause command.""" - if self.state != MediaPlayerState.STANDBY: + if self.state != MediaPlayerState.OFF: await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index d666ec44f80..de5352191d7 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -25,7 +25,6 @@ class RomyVacuumCoordinator(DataUpdateCoordinator[None]): name=DOMAIN, update_interval=UPDATE_INTERVAL, ) - self.hass = hass self.romy = romy async def _async_update_data(self) -> None: diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 14c7ac3af3e..eb1b3696102 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -51,11 +51,6 @@ class IRobotEntity(Entity): """Return the uniqueid of the vacuum cleaner.""" return self.robot_unique_id - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self.vacuum_state.get("batPct") - @property def run_stats(self): """Return the run stats.""" diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 3a98bedcd94..ae82424ec34 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -35,7 +35,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda self: self.battery_level, + value_fn=lambda self: self.vacuum_state.get("batPct"), ), RoombaSensorEntityDescription( key="battery_cycles", diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 10606814a35..0c24301f2af 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -24,8 +24,7 @@ from .entity import IRobotEntity from .models import RoombaData SUPPORT_IROBOT = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.PAUSE + VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.START diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index 2f2967c5789..b2a491c8d28 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -31,7 +31,7 @@ async def async_setup_entry( if dev_id in event_entities: return # new player! - event_entity = RoonEventEntity(roon_server, player_data) + event_entity = RoonEventEntity(roon_server, player_data, config_entry.entry_id) event_entities.add(dev_id) async_add_entities([event_entity]) @@ -50,13 +50,14 @@ class RoonEventEntity(EventEntity): _attr_event_types = ["volume_up", "volume_down", "mute_toggle"] _attr_translation_key = "volume" - def __init__(self, server, player_data): + def __init__(self, server, player_data, entry_id): """Initialize the entity.""" self._server = server self._player_data = player_data player_name = player_data["display_name"] self._attr_name = f"{player_name} roon volume" self._attr_unique_id = self._player_data["dev_id"] + self._entry_id = entry_id if self._player_data.get("source_controls"): dev_model = self._player_data["source_controls"][0].get("display_name") @@ -69,7 +70,7 @@ class RoonEventEntity(EventEntity): name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, - via_device=(DOMAIN, self._server.roon_id), + via_device=(DOMAIN, self._entry_id), ) def _roonapi_volume_callback( diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 4a87601a24f..0c4f8394989 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -72,7 +72,7 @@ async def async_setup_entry( dev_id = player_data["dev_id"] if dev_id not in media_players: # new player! - media_player = RoonDevice(roon_server, player_data) + media_player = RoonDevice(roon_server, player_data, config_entry.entry_id) media_players.add(dev_id) async_add_entities([media_player]) else: @@ -106,7 +106,7 @@ class RoonDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY_MEDIA ) - def __init__(self, server, player_data): + def __init__(self, server, player_data, entry_id): """Initialize Roon device object.""" self._remove_signal_status = None self._server = server @@ -125,6 +125,7 @@ class RoonDevice(MediaPlayerEntity): self._attr_volume_level = 0 self._volume_fixed = True self._volume_incremental = False + self._entry_id = entry_id self.update_data(player_data) async def async_added_to_hass(self) -> None: @@ -166,7 +167,7 @@ class RoonDevice(MediaPlayerEntity): name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, - via_device=(DOMAIN, self._server.roon_id), + via_device=(DOMAIN, self._entry_id), ) def update_data(self, player_data=None): diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index 9647c419da0..7a8c0bb4fbc 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -6,6 +6,10 @@ from aiorussound import CommandError DOMAIN = "russound_rio" +RUSSOUND_MEDIA_TYPE_PRESET = "preset" + +SELECT_SOURCE_DELAY = 0.5 + RUSSOUND_RIO_EXCEPTIONS = ( CommandError, ConnectionRefusedError, diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index a74a1887836..efaf8f195ad 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.6.1"], + "requirements": ["aiorussound==4.8.1"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/russound_rio/media_browser.py b/homeassistant/components/russound_rio/media_browser.py new file mode 100644 index 00000000000..7e5ca741f90 --- /dev/null +++ b/homeassistant/components/russound_rio/media_browser.py @@ -0,0 +1,97 @@ +"""Support for Russound media browsing.""" + +from aiorussound import RussoundClient, Zone +from aiorussound.const import FeatureFlag +from aiorussound.util import is_feature_supported + +from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.core import HomeAssistant + + +async def async_browse_media( + hass: HomeAssistant, + client: RussoundClient, + media_content_id: str | None, + media_content_type: str | None, + zone: Zone, +) -> BrowseMedia: + """Browse media.""" + if media_content_type == "presets": + return await _presets_payload(_find_presets_by_zone(client, zone)) + + return await _root_payload(hass, _find_presets_by_zone(client, zone)) + + +async def _root_payload( + hass: HomeAssistant, presets_by_zone: dict[int, dict[int, str]] +) -> BrowseMedia: + """Return root payload for Russound RIO.""" + children: list[BrowseMedia] = [] + + if presets_by_zone: + children.append( + BrowseMedia( + title="Presets", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="presets", + thumbnail="https://brands.home-assistant.io/_/russound_rio/logo.png", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMedia( + title="Russound", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="root", + can_play=False, + can_expand=True, + children=children, + ) + + +async def _presets_payload(presets_by_zone: dict[int, dict[int, str]]) -> BrowseMedia: + """Create payload to list presets.""" + children: list[BrowseMedia] = [] + for source_id, presets in presets_by_zone.items(): + for preset_id, preset_name in presets.items(): + children.append( + BrowseMedia( + title=preset_name, + media_class=MediaClass.CHANNEL, + media_content_id=f"{source_id},{preset_id}", + media_content_type="preset", + can_play=True, + can_expand=False, + ) + ) + + return BrowseMedia( + title="Presets", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="presets", + can_play=False, + can_expand=True, + children=children, + ) + + +def _find_presets_by_zone( + client: RussoundClient, zone: Zone +) -> dict[int, dict[int, str]]: + """Returns a dict by {source_id: {preset_id: preset_name}}.""" + assert client.rio_version + return { + source_id: source.presets + for source_id, source in client.sources.items() + if source.presets + and ( + not is_feature_supported( + client.rio_version, FeatureFlag.SUPPORT_ZONE_SOURCE_EXCLUSION + ) + or source_id in zone.enabled_sources + ) + } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index aaaad05a2bc..a09c663a983 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -2,9 +2,10 @@ from __future__ import annotations +import asyncio import datetime as dt import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from aiorussound import Controller from aiorussound.const import FeatureFlag @@ -12,6 +13,7 @@ from aiorussound.models import PlayStatus, Source from aiorussound.util import is_feature_supported from homeassistant.components.media_player import ( + BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -19,9 +21,11 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RussoundConfigEntry +from . import RussoundConfigEntry, media_browser +from .const import DOMAIN, RUSSOUND_MEDIA_TYPE_PRESET, SELECT_SOURCE_DELAY from .entity import RussoundBaseEntity, command _LOGGER = logging.getLogger(__name__) @@ -45,19 +49,32 @@ async def async_setup_entry( ) +def _parse_preset_source_id(media_id: str) -> tuple[int | None, int]: + source_id = None + if "," in media_id: + source_id_str, preset_id_str = media_id.split(",", maxsplit=1) + source_id = int(source_id_str.strip()) + preset_id = int(preset_id_str.strip()) + else: + preset_id = int(media_id) + return source_id, preset_id + + class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): """Representation of a Russound Zone.""" _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_media_content_type = MediaType.MUSIC _attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_SET + MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PLAY_MEDIA ) _attr_name = None @@ -117,7 +134,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @property def media_title(self) -> str | None: """Title of current playing media.""" - return self._source.song_name + return self._source.song_name or self._source.channel @property def media_artist(self) -> str | None: @@ -215,3 +232,47 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): async def async_media_seek(self, position: float) -> None: """Seek to a position in the current media.""" await self._zone.set_seek_time(int(position)) + + @command + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play media on the Russound zone.""" + + if media_type != RUSSOUND_MEDIA_TYPE_PRESET: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={ + "media_type": media_type, + }, + ) + + try: + source_id, preset_id = _parse_preset_source_id(media_id) + except ValueError as ve: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="preset_non_integer", + translation_placeholders={"preset_id": media_id}, + ) from ve + if source_id: + await self._zone.select_source(source_id) + await asyncio.sleep(SELECT_SOURCE_DELAY) + if not self._source.presets or preset_id not in self._source.presets: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_preset", + translation_placeholders={"preset_id": media_id}, + ) + await self._zone.restore_preset(preset_id) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the media browsing helper.""" + return await media_browser.async_browse_media( + self.hass, self._client, media_content_id, media_content_type, self._zone + ) diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index aa9a1cbc65d..9149a22aac0 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -67,6 +67,15 @@ }, "command_error": { "message": "Error executing {function_name} on entity {entity_id}" + }, + "unsupported_media_type": { + "message": "Unsupported media type for Russound zone: {media_type}" + }, + "missing_preset": { + "message": "The specified preset is not available for this source: {preset_id}" + }, + "preset_non_integer": { + "message": "Preset must be an integer, got: {preset_id}" } } } diff --git a/homeassistant/components/ruuvitag_ble/manifest.json b/homeassistant/components/ruuvitag_ble/manifest.json index fa8ec80423c..1051c9613a6 100644 --- a/homeassistant/components/ruuvitag_ble/manifest.json +++ b/homeassistant/components/ruuvitag_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble", "iot_class": "local_push", - "requirements": ["ruuvitag-ble==0.1.2"] + "requirements": ["ruuvitag-ble==0.2.1"] } diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index 57248d547ba..44311fd12eb 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from sensor_state_data import ( DeviceKey, - SensorDescription, SensorDeviceClass as SSDSensorDeviceClass, SensorUpdate, Units, @@ -32,53 +31,108 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN SENSOR_DESCRIPTIONS = { - (SSDSensorDeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( + "temperature": SensorEntityDescription( key=f"{SSDSensorDeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), - (SSDSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( + "humidity": SensorEntityDescription( key=f"{SSDSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - (SSDSensorDeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription( + "pressure": SensorEntityDescription( key=f"{SSDSensorDeviceClass.PRESSURE}_{Units.PRESSURE_HPA}", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), - ( - SSDSensorDeviceClass.VOLTAGE, - Units.ELECTRIC_POTENTIAL_MILLIVOLT, - ): SensorEntityDescription( + "voltage": SensorEntityDescription( key=f"{SSDSensorDeviceClass.VOLTAGE}_{Units.ELECTRIC_POTENTIAL_MILLIVOLT}", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), - ( - SSDSensorDeviceClass.SIGNAL_STRENGTH, - Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - ): SensorEntityDescription( + "signal_strength": SensorEntityDescription( key=f"{SSDSensorDeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - (SSDSensorDeviceClass.COUNT, None): SensorEntityDescription( + "movement_counter": SensorEntityDescription( key="movement_counter", + translation_key="movement_counter", state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), + # Acceleration keys exported in newer versions of ruuvitag-ble + "acceleration_x": SensorEntityDescription( + key=f"acceleration_x_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_x", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "acceleration_y": SensorEntityDescription( + key=f"acceleration_y_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_y", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "acceleration_z": SensorEntityDescription( + key=f"acceleration_z_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_z", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "acceleration_total": SensorEntityDescription( + key=f"acceleration_total_{Units.ACCELERATION_METERS_PER_SQUARE_SECOND}", + translation_key="acceleration_total", + native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + # Keys exported for dataformat 06 sensors in newer versions of ruuvitag-ble + "pm25": SensorEntityDescription( + key=f"{SSDSensorDeviceClass.PM25}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + "carbon_dioxide": SensorEntityDescription( + key=f"{SSDSensorDeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=Units.CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + "illuminance": SensorEntityDescription( + key=f"{SSDSensorDeviceClass.ILLUMINANCE}_{Units.LIGHT_LUX}", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=Units.LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), + "voc_index": SensorEntityDescription( + key="voc_index", + translation_key="voc_index", + state_class=SensorStateClass.MEASUREMENT, + ), + "nox_index": SensorEntityDescription( + key="nox_index", + translation_key="nox_index", + state_class=SensorStateClass.MEASUREMENT, + ), } @@ -89,37 +143,28 @@ def _device_key_to_bluetooth_entity_key( return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) -def _to_sensor_key( - description: SensorDescription, -) -> tuple[SSDSensorDeviceClass, Units | None]: - assert description.device_class is not None - return (description.device_class, description.native_unit_of_measurement) - - def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a bluetooth data update.""" + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = {} + entity_data = {} + for device_key, sensor_values in sensor_update.entity_values.items(): + bek = _device_key_to_bluetooth_entity_key(device_key) + entity_data[bek] = sensor_values.native_value + for device_key in sensor_update.entity_descriptions: + bek = _device_key_to_bluetooth_entity_key(device_key) + if sk_description := SENSOR_DESCRIPTIONS.get(device_key.key): + entity_descriptions[bek] = sk_description + return PassiveBluetoothDataUpdate( devices={ device_id: sensor_device_info_to_hass_device_info(device_info) for device_id, device_info in sensor_update.devices.items() }, - entity_descriptions={ - _device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - _to_sensor_key(description) - ] - for device_key, description in sensor_update.entity_descriptions.items() - if _to_sensor_key(description) in SENSOR_DESCRIPTIONS - }, - entity_data={ - _device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value - for device_key, sensor_values in sensor_update.entity_values.items() - }, - entity_names={ - _device_key_to_bluetooth_entity_key(device_key): sensor_values.name - for device_key, sensor_values in sensor_update.entity_values.items() - }, + entity_descriptions=entity_descriptions, + entity_data=entity_data, + entity_names={}, ) diff --git a/homeassistant/components/ruuvitag_ble/strings.json b/homeassistant/components/ruuvitag_ble/strings.json index 16a80220a20..0abb8343c65 100644 --- a/homeassistant/components/ruuvitag_ble/strings.json +++ b/homeassistant/components/ruuvitag_ble/strings.json @@ -18,5 +18,30 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "acceleration_total": { + "name": "Acceleration total" + }, + "acceleration_x": { + "name": "Acceleration X" + }, + "acceleration_y": { + "name": "Acceleration Y" + }, + "acceleration_z": { + "name": "Acceleration Z" + }, + "movement_counter": { + "name": "Movement counter" + }, + "nox_index": { + "name": "NOx index" + }, + "voc_index": { + "name": "VOC index" + } + } } } diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index dbde1ee1ef3..e2b9f8631d8 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -124,6 +124,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self._model: str | None = None self._connect_result: str | None = None self._method: str | None = None + self._port: int | None = None self._name: str | None = None self._title: str = "" self._id: int | None = None @@ -199,33 +200,37 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_create_bridge(self) -> None: """Create the bridge.""" - result, method, _info = await self._async_get_device_info_and_method() + result = await self._async_load_device_info() if result not in SUCCESSFUL_RESULTS: LOGGER.debug("No working config found for %s", self._host) raise AbortFlow(result) - assert method is not None - self._bridge = SamsungTVBridge.get_bridge(self.hass, method, self._host) + assert self._method is not None + self._bridge = SamsungTVBridge.get_bridge( + self.hass, self._method, self._host, self._port + ) - async def _async_get_device_info_and_method( + async def _async_load_device_info( self, - ) -> tuple[str, str | None, dict[str, Any] | None]: + ) -> str: """Get device info and method only once.""" if self._connect_result is None: - result, _, method, info = await async_get_device_info(self.hass, self._host) + result, port, method, info = await async_get_device_info( + self.hass, self._host + ) self._connect_result = result self._method = method + self._port = port self._device_info = info if not method: LOGGER.debug("Host:%s did not return device info", self._host) - return result, None, None - return self._connect_result, self._method, self._device_info + return self._connect_result async def _async_get_and_check_device_info(self) -> bool: """Try to get the device info.""" - result, _method, info = await self._async_get_device_info_and_method() + result = await self._async_load_device_info() if result not in SUCCESSFUL_RESULTS: raise AbortFlow(result) - if not info: + if not (info := self._device_info): return False dev_info = info.get("device", {}) assert dev_info is not None diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index dc8133a1b1f..1b927757a39 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -34,12 +34,13 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], + "quality_scale": "bronze", "requirements": [ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.44.0" + "async-upnp-client==0.45.0" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/quality_scale.yaml b/homeassistant/components/samsungtv/quality_scale.yaml new file mode 100644 index 00000000000..845ebfe6e46 --- /dev/null +++ b/homeassistant/components/samsungtv/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: 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: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: no 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 configuration options so far + docs-installation-parameters: done + entity-unavailable: + status: todo + comment: check super().unavailable + 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: + status: todo + comment: add info about polling the bridge every 10 seconds + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: todo + comment: be more specific about supported devices + docs-supported-functions: + status: todo + comment: be more specific about supported functions + docs-troubleshooting: + status: todo + comment: split that up to proper troubleshooting and known limitations section + docs-use-cases: done + dynamic-devices: + status: exempt + comment: device type integration + entity-category: + status: exempt + comment: no config or diagnostic entities + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: only 2 main entities + entity-translations: + status: exempt + comment: using only device name + exception-translations: done + icon-translations: + status: done + comment: no custom icons, only default icons + reconfiguration-flow: + status: todo + comment: handle at least host change + repair-issues: + status: exempt + comment: no known repair use case so far + stale-devices: + status: exempt + comment: device type integration + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: Requirements 'getmac==0.9.5', 'samsungctl[websocket]==0.7.1' and 'wakeonlan==2.1.0' appear untyped diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 6251e65b2f8..aa0e77e0b76 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -50,7 +50,7 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Check your TV's External Device Manager settings to authorize Home Assistant.", - "id_missing": "This Samsung device doesn't have a SerialNumber.", + "id_missing": "This Samsung device doesn't have a serial number to identify it.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "not_supported": "This Samsung device is currently not supported.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 893c30dfd41..b71afe01e56 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.4.0"] + "requirements": ["pyschlage==2025.7.3"] } diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 28e08372d68..8b9d7ddf37e 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"] + "requirements": ["beautifulsoup4==4.13.3", "lxml==6.0.0"] } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 80d53a2c8b1..3e7f416166b 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -7,8 +7,7 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( CONF_ATTRIBUTE, CONF_DEVICE_CLASS, @@ -218,17 +217,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - self._process_manual_data(variables) - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) @property diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index d46f63c9516..91452287ce7 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -139,6 +139,7 @@ "selector": { "device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "area": "[%key:component::sensor::entity_component::area::name%]", @@ -155,6 +156,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -184,13 +186,14 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 434b8921bc2..2a91fcd6c8e 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.10.0"] + "requirements": ["screenlogicpy==0.10.2"] } diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 5a0546b1aa2..1ed9a1bbefc 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -8,24 +8,11 @@ from typing import TYPE_CHECKING, Any from pysensibo.model import SensiboDevice -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.select import ( - DOMAIN as SELECT_DOMAIN, - SelectEntity, - SelectEntityDescription, -) +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import SensiboConfigEntry -from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -42,16 +29,6 @@ class SensiboSelectEntityDescription(SelectEntityDescription): transformation: Callable[[SensiboDevice], dict | None] -HORIZONTAL_SWING_MODE_TYPE = SensiboSelectEntityDescription( - key="horizontalSwing", - data_key="horizontal_swing_mode", - value_fn=lambda data: data.horizontal_swing_mode, - options_fn=lambda data: data.horizontal_swing_modes, - translation_key="horizontalswing", - transformation=lambda data: data.horizontal_swing_modes_translated, - entity_registry_enabled_default=False, -) - DEVICE_SELECT_TYPES = ( SensiboSelectEntityDescription( key="light", @@ -73,43 +50,6 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboSelect] = [] - - entity_registry = er.async_get(hass) - for device_id, device_data in coordinator.data.parsed.items(): - if entity_id := entity_registry.async_get_entity_id( - SELECT_DOMAIN, DOMAIN, f"{device_id}-horizontalSwing" - ): - entity = entity_registry.async_get(entity_id) - if entity and entity.disabled: - entity_registry.async_remove(entity_id) - async_delete_issue( - hass, - DOMAIN, - "deprecated_entity_horizontalswing", - ) - elif entity and HORIZONTAL_SWING_MODE_TYPE.key in device_data.full_features: - entities.append( - SensiboSelect(coordinator, device_id, HORIZONTAL_SWING_MODE_TYPE) - ) - if automations_with_entity(hass, entity_id) or scripts_with_entity( - hass, entity_id - ): - async_create_issue( - hass, - DOMAIN, - "deprecated_entity_horizontalswing", - breaks_in_ha_version="2025.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity_horizontalswing", - translation_placeholders={ - "name": str(entity.name or entity.original_name), - "entity": entity_id, - }, - ) - async_add_entities(entities) - added_devices: set[str] = set() def _add_remove_devices() -> None: diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 4dce104d1c7..1071a7739f6 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -77,22 +77,6 @@ } }, "select": { - "horizontalswing": { - "name": "Horizontal swing", - "state": { - "stopped": "[%key:common::state::off%]", - "fixedleft": "Fixed left", - "fixedcenterleft": "Fixed center left", - "fixedcenter": "Fixed center", - "fixedcenterright": "Fixed center right", - "fixedright": "Fixed right", - "fixedleftright": "Fixed left right", - "rangecenter": "Range center", - "rangefull": "Range full", - "rangeleft": "Range left", - "rangeright": "Range right" - } - }, "light": { "name": "[%key:component::light::title%]", "state": { @@ -153,14 +137,16 @@ "name": "Horizontal swing", "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + "fixedleft": "Fixed left", + "fixedcenterleft": "Fixed center left", + "fixedcenter": "Fixed center", + "fixedcenterright": "Fixed center right", + "fixedright": "Fixed right", + "fixedleftright": "Fixed left right", + "rangecenter": "Range center", + "rangefull": "Range full", + "rangeleft": "Range left", + "rangeright": "Range right" } }, "light": { @@ -239,14 +225,14 @@ "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::name%]", "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + "fixedleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]" } }, "light": { @@ -383,7 +369,7 @@ "rangetop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangetop%]", "rangemiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangemiddle%]", "rangebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangebottom%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]", "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } @@ -391,16 +377,16 @@ "swing_horizontal_mode": { "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", - "rangeleft": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeleft%]", - "rangeright": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeright%]" + "fixedleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]", + "rangeleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangeleft%]", + "rangeright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangeright%]" } } } @@ -590,11 +576,5 @@ "mode_not_exist": { "message": "The entity does not support the chosen mode" } - }, - "issues": { - "deprecated_entity_horizontalswing": { - "title": "The Sensibo {name} entity is deprecated", - "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\nDisable `{entity}` and reload the config entry or restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9948860fd5f..56171707338 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -34,6 +34,7 @@ from homeassistant.util.enum import try_parse_enum from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 + AMBIGUOUS_UNITS, ATTR_LAST_RESET, ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -314,7 +315,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return _numeric_state_expected( try_parse_enum(SensorDeviceClass, self.device_class), self.state_class, - self.native_unit_of_measurement, + self.__native_unit_of_measurement_compat, self.suggested_display_precision, ) @@ -366,7 +367,8 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Make sure we can convert the units if ( (unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None - or self.native_unit_of_measurement not in unit_converter.VALID_UNITS + or self.__native_unit_of_measurement_compat + not in unit_converter.VALID_UNITS or suggested_unit_of_measurement not in unit_converter.VALID_UNITS ): if not self._invalid_suggested_unit_of_measurement_reported: @@ -387,7 +389,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if suggested_unit_of_measurement is None: # Fallback to unit suggested by the unit conversion rules from device class suggested_unit_of_measurement = self.hass.config.units.get_converted_unit( - self.device_class, self.native_unit_of_measurement + self.device_class, self.__native_unit_of_measurement_compat ) if suggested_unit_of_measurement is None and ( @@ -396,7 +398,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # If the device class is not known by the unit system but has a unit converter, # fall back to the unit suggested by the unit converter's unit class. suggested_unit_of_measurement = self.hass.config.units.get_converted_unit( - unit_converter.UNIT_CLASS, self.native_unit_of_measurement + unit_converter.UNIT_CLASS, self.__native_unit_of_measurement_compat ) if suggested_unit_of_measurement is None: @@ -468,6 +470,16 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return self.entity_description.native_unit_of_measurement return None + @final + @property + def __native_unit_of_measurement_compat(self) -> str | None: + """Process ambiguous units.""" + native_unit_of_measurement = self.native_unit_of_measurement + return AMBIGUOUS_UNITS.get( + native_unit_of_measurement, + native_unit_of_measurement, + ) + @cached_property def suggested_unit_of_measurement(self) -> str | None: """Return the unit which should be used for the sensor's state. @@ -503,7 +515,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self._sensor_option_unit_of_measurement is not UNDEFINED: return self._sensor_option_unit_of_measurement - native_unit_of_measurement = self.native_unit_of_measurement + native_unit_of_measurement = self.__native_unit_of_measurement_compat # Second priority, for non registered entities: unit suggested by integration if not self.registry_entry and ( @@ -523,7 +535,9 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Fourth priority: Unit translation if (translation_key := self._unit_of_measurement_translation_key) and ( unit_of_measurement - := self.platform.default_language_platform_translations.get(translation_key) + := self.platform_data.default_language_platform_translations.get( + translation_key + ) ): if native_unit_of_measurement is not None: raise ValueError( @@ -541,7 +555,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @override def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" - native_unit_of_measurement = self.native_unit_of_measurement + native_unit_of_measurement = self.__native_unit_of_measurement_compat unit_of_measurement = self.unit_of_measurement value = self.native_value # For the sake of validation, we can ignore custom device classes @@ -763,7 +777,8 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return display_precision default_unit_of_measurement = ( - self.suggested_unit_of_measurement or self.native_unit_of_measurement + self.suggested_unit_of_measurement + or self.__native_unit_of_measurement_compat ) if default_unit_of_measurement is None: return display_precision @@ -841,7 +856,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): (sensor_options := self.registry_entry.options.get(primary_key)) and secondary_key in sensor_options and (device_class := self.device_class) in UNIT_CONVERTERS - and self.native_unit_of_measurement + and self.__native_unit_of_measurement_compat in UNIT_CONVERTERS[device_class].VALID_UNITS and (custom_unit := sensor_options[secondary_key]) in UNIT_CONVERTERS[device_class].VALID_UNITS diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 994c29b6bbf..e09923ad940 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -8,6 +8,8 @@ from typing import Final import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -45,6 +47,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -62,6 +65,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -107,10 +111,16 @@ class SensorDeviceClass(StrEnum): """ # Numerical device classes, these should be aligned with NumberDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ + APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" @@ -158,7 +168,7 @@ class SensorDeviceClass(StrEnum): CONDUCTIVITY = "conductivity" """Conductivity. - Unit of measurement: `S/cm`, `mS/cm`, `µS/cm` + Unit of measurement: `S/cm`, `mS/cm`, `μS/cm` """ CURRENT = "current" @@ -190,7 +200,7 @@ class SensorDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `μs` """ ENERGY = "energy" @@ -270,25 +280,25 @@ class SensorDeviceClass(StrEnum): NITROGEN_DIOXIDE = "nitrogen_dioxide" """Amount of NO2. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ NITROGEN_MONOXIDE = "nitrogen_monoxide" """Amount of NO. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ NITROUS_OXIDE = "nitrous_oxide" """Amount of N2O. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ OZONE = "ozone" """Amount of O3. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ PH = "ph" @@ -300,19 +310,19 @@ class SensorDeviceClass(StrEnum): PM1 = "pm1" """Particulate matter <= 1 μm. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ PM10 = "pm10" """Particulate matter <= 10 μm. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ PM25 = "pm25" """Particulate matter <= 2.5 μm. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ POWER_FACTOR = "power_factor" @@ -362,7 +372,7 @@ class SensorDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var`, `kvar` + Unit of measurement: `mvar`, `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -390,7 +400,7 @@ class SensorDeviceClass(StrEnum): SULPHUR_DIOXIDE = "sulphur_dioxide" """Amount of SO2. - Unit of measurement: `µg/m³` + Unit of measurement: `μg/m³` """ TEMPERATURE = "temperature" @@ -402,7 +412,7 @@ class SensorDeviceClass(StrEnum): VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" """Amount of VOC. - Unit of measurement: `µg/m³`, `mg/m³` + Unit of measurement: `μg/m³`, `mg/m³` """ VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" @@ -414,7 +424,7 @@ class SensorDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` + Unit of measurement: `V`, `mV`, `μV`, `kV`, `MV` """ VOLUME = "volume" @@ -461,7 +471,7 @@ class SensorDeviceClass(StrEnum): Weight is used instead of mass to fit with every day language. Unit of measurement: `MASS_*` units - - SI / metric: `µg`, `mg`, `g`, `kg` + - SI / metric: `μg`, `mg`, `g`, `kg` - USCS / imperial: `oz`, `lb` """ @@ -521,6 +531,8 @@ STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.APPARENT_POWER: ApparentPowerConverter, + SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter, SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, @@ -540,6 +552,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRECIPITATION_INTENSITY: SpeedConverter, SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter, + SensorDeviceClass.REACTIVE_POWER: ReactivePowerConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: MassVolumeConcentrationConverter, @@ -554,6 +567,10 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = } DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), SensorDeviceClass.AQI: {None}, SensorDeviceClass.AREA: set(UnitOfArea), @@ -651,6 +668,7 @@ DEFAULT_PRECISION_LIMIT = 2 # have 0 decimals, that one should be used and not mW, even though mW also should have # 0 decimals. Otherwise the smaller units will have more decimals than expected. UNITS_PRECISION = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: (CONCENTRATION_GRAMS_PER_CUBIC_METER, 1), SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0), SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0), @@ -691,6 +709,7 @@ UNITS_PRECISION = { } DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AREA: set(SensorStateClass), @@ -770,3 +789,16 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]] = { SensorStateClass.MEASUREMENT_ANGLE: {DEGREE}, } + +# We translate units that were using using the legacy coding of μ \u00b5 +# to units using recommended coding of μ \u03bc +AMBIGUOUS_UNITS: dict[str | None, str] = { + "\u00b5Sv/h": "μSv/h", # aranet: radiation rate + "\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM, + "\u00b5V": UnitOfElectricPotential.MICROVOLT, + "\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + "\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light + "\u00b5g": UnitOfMass.MICROGRAMS, + "\u00b5s": UnitOfTime.MICROSECONDS, +} diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 2b1eb350c3e..1ad5fe12e99 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -33,6 +33,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" +CONF_IS_ABSOLUTE_HUMIDITY = "is_absolute_humidity" CONF_IS_APPARENT_POWER = "is_apparent_power" CONF_IS_AQI = "is_aqi" CONF_IS_AREA = "is_area" @@ -88,6 +89,7 @@ CONF_IS_WIND_DIRECTION = "is_wind_direction" CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_IS_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}], @@ -159,6 +161,7 @@ CONDITION_SCHEMA = vol.All( vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_IS_ABSOLUTE_HUMIDITY, CONF_IS_APPARENT_POWER, CONF_IS_AQI, CONF_IS_AREA, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d44611a49db..ae2125962e8 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -32,6 +32,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" +CONF_ABSOLUTE_HUMIDITY = "absolute_humidity" CONF_APPARENT_POWER = "apparent_power" CONF_AQI = "aqi" CONF_AREA = "area" @@ -87,6 +88,7 @@ CONF_WIND_DIRECTION = "wind_direction" CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}], @@ -159,6 +161,7 @@ TRIGGER_SCHEMA = vol.All( vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_ABSOLUTE_HUMIDITY, CONF_APPARENT_POWER, CONF_AQI, CONF_AREA, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 05311868fc6..cea955e061c 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:eye" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index c321caa616d..c20a3e2e1ae 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -45,6 +45,7 @@ from homeassistant.util.enum import try_parse_enum from homeassistant.util.hass_dict import HassKey from .const import ( + AMBIGUOUS_UNITS, ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN, @@ -79,7 +80,7 @@ EQUIVALENT_UNITS = { "ft3": UnitOfVolume.CUBIC_FEET, "m3": UnitOfVolume.CUBIC_METERS, "ft³/m": UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, -} +} | AMBIGUOUS_UNITS # Keep track of entities for which a warning about decreasing value has been logged diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index dbda96a591e..dfea54c54f7 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -2,6 +2,7 @@ "title": "Sensor", "device_automation": { "condition_type": { + "is_absolute_humidity": "Current {entity_name} absolute humidity", "is_apparent_power": "Current {entity_name} apparent power", "is_aqi": "Current {entity_name} air quality index", "is_area": "Current {entity_name} area", @@ -24,7 +25,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", - "is_monetary": "Current {entity_name} balance", + "is_monetary": "Current {entity_name} monetary balance", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -57,6 +58,7 @@ "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { + "absolute_humidity": "{entity_name} absolute humidity changes", "apparent_power": "{entity_name} apparent power changes", "aqi": "{entity_name} air quality index changes", "area": "{entity_name} area changes", @@ -79,7 +81,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", - "monetary": "{entity_name} balance changes", + "monetary": "{entity_name} monetary balance changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", @@ -148,6 +150,9 @@ "duration": { "name": "Duration" }, + "absolute_humidity": { + "name": "Absolute humidity" + }, "apparent_power": { "name": "Apparent power" }, @@ -218,7 +223,7 @@ "name": "Moisture" }, "monetary": { - "name": "Balance" + "name": "Monetary balance" }, "nitrogen_dioxide": { "name": "Nitrogen dioxide" diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 6107a6057d1..413e9424b15 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==11.2.1"] + "requirements": ["Pillow==11.3.0"] } diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 988a01f0022..bbf2fcf2638 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -48,4 +48,3 @@ SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_PACKAGE_FRIENDLY_NAME = "package_friendly_name" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 531ff2aea43..bd39b00071f 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -6,7 +6,7 @@ from pyseventeentrack.package import PACKAGE_STATUS_MAP, Package import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_FRIENDLY_NAME, ATTR_LOCATION from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -20,7 +20,6 @@ from homeassistant.util import slugify from . import SeventeenTrackCoordinator from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, ATTR_ORIGIN_COUNTRY, diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index a2d65e9819d..1987453a80d 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sfr_box", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["sfrbox-api==0.0.11"] + "requirements": ["sfrbox-api==0.0.12"] } diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 35e9b1869ff..5139ec52bad 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -27,7 +27,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your SFR device." + "host": "The hostname, IP address, or full URL of your SFR device. e.g.: '192.168.1.1' or 'https://sfrbox.example.com'" }, "description": "Setting the credentials is optional, but enables additional functionality." } diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 9f9009693e5..c29fc582462 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.1.0"] + "requirements": ["sharkiq==1.1.1"] } diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 75fedf9b16d..5582ab488df 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,7 +56,10 @@ from .coordinator import ( ShellyRpcCoordinator, ShellyRpcPollingCoordinator, ) -from .repairs import async_manage_ble_scanner_firmware_unsupported_issue +from .repairs import ( + async_manage_ble_scanner_firmware_unsupported_issue, + async_manage_outbound_websocket_incorrectly_enabled_issue, +) from .utils import ( async_create_issue_unsupported_firmware, get_coap_context, @@ -295,7 +298,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) translation_key="firmware_unsupported", translation_placeholders={"device": entry.title}, ) - runtime_data.rpc_zigbee_enabled = device.zigbee_enabled + runtime_data.rpc_zigbee_firmware = device.zigbee_firmware runtime_data.rpc_supports_scripts = await device.supports_scripts() if runtime_data.rpc_supports_scripts: runtime_data.rpc_script_events = await get_rpc_scripts_event_types( @@ -327,6 +330,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) hass, entry, ) + async_manage_outbound_websocket_incorrectly_enabled_issue( + hass, + entry, + ) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index ad03a373dba..209fa4af54a 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -19,20 +19,14 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import ( - get_block_device_info, - get_blu_trv_device_info, - get_device_entry_gen, - get_rpc_device_info, - get_rpc_key_ids, -) +from .entity import get_entity_block_device_info, get_entity_rpc_device_info +from .utils import get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_ids PARALLEL_UPDATES = 0 @@ -234,20 +228,9 @@ class ShellyButton(ShellyBaseButton): self._attr_unique_id = f"{coordinator.mac}_{description.key}" if isinstance(coordinator, ShellyBlockCoordinator): - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator) else: - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - suggested_area=coordinator.suggested_area, - ) - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator) async def _press_method(self) -> None: """Press method.""" diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index abc387f3efd..3a495c9f4ac 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -38,10 +38,9 @@ from .const import ( SHTRV_01_TEMPERATURE_SETTINGS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyRpcEntity, rpc_call +from .entity import ShellyRpcEntity, get_entity_block_device_info, rpc_call from .utils import ( async_remove_shelly_entity, - get_block_device_info, get_block_entity_name, get_blu_trv_device_info, get_device_entry_gen, @@ -210,12 +209,7 @@ class BlockSleepingClimate( ] elif entry is not None: self._unique_id = entry.unique_id - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - sensor_block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, sensor_block) self._attr_name = get_block_entity_name( self.coordinator.device, sensor_block, None ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index bde57f6f9bc..d310f3525c5 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -475,8 +475,8 @@ class OptionsFlowHandler(OptionsFlow): return self.async_abort(reason="cannot_connect") if not supports_scripts: return self.async_abort(reason="no_scripts_support") - if self.config_entry.runtime_data.rpc_zigbee_enabled: - return self.async_abort(reason="zigbee_enabled") + if self.config_entry.runtime_data.rpc_zigbee_firmware: + return self.async_abort(reason="zigbee_firmware") if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 7462766e2d4..60fc5b03d13 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -237,6 +237,9 @@ NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{unique}" +OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = ( + "outbound_websocket_incorrectly_enabled_{unique}" +) GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index fa434588b34..eba6b846fe4 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -94,7 +94,7 @@ class ShellyEntryData: rpc_poll: ShellyRpcPollingCoordinator | None = None rpc_script_events: dict[int, list[str]] | None = None rpc_supports_scripts: bool | None = None - rpc_zigbee_enabled: bool | None = None + rpc_zigbee_firmware: bool | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] @@ -145,11 +145,21 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + @cached_property + def configuration_url(self) -> str: + """Return the configuration URL for the device.""" + return f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}" + @cached_property def model(self) -> str: """Model of the device.""" return cast(str, self.config_entry.data[CONF_MODEL]) + @cached_property + def model_name(self) -> str | None: + """Model name of the device.""" + return get_shelly_model_name(self.model, self.sleep_period, self.device) + @cached_property def mac(self) -> str: """Mac address of the device.""" @@ -163,7 +173,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @property def sleep_period(self) -> int: """Sleep period of the device.""" - return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) + return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) # type: ignore[no-any-return] def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" @@ -175,11 +185,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( connections={(CONNECTION_NETWORK_MAC, self.mac)}, identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=get_shelly_model_name(self.model, self.sleep_period, self.device), + model=self.model_name, model_id=self.model, sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.config_entry)}", - configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", + configuration_url=self.configuration_url, ) # We want to use the main device area as the suggested area for sub-devices. if (area_id := device_entry.area_id) is not None: @@ -730,7 +740,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if not self.sleep_period: if ( self.config_entry.runtime_data.rpc_supports_scripts - and not self.config_entry.runtime_data.rpc_zigbee_enabled + and not self.config_entry.runtime_data.rpc_zigbee_firmware ): await self._async_connect_ble_scanner() else: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 5a420a4543b..97946ddd8f3 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -13,6 +13,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry @@ -86,7 +87,10 @@ def async_setup_block_attribute_entities( coordinator.device.settings, block ): domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}" + unique_id = sensor_class( + coordinator, block, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) else: entities.append( @@ -192,8 +196,12 @@ def async_setup_rpc_attribute_entities( if description.removal_condition and description.removal_condition( coordinator.device.config, coordinator.device.status, key ): - domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{key}-{sensor_id}" + entity_class = get_entity_class(sensor_class, description) + domain = entity_class.__module__.split(".")[-1] + unique_id = entity_class( + coordinator, key, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) elif description.use_polling_coordinator: if not sleep_period: @@ -361,12 +369,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): super().__init__(coordinator) self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, block) self._attr_unique_id = f"{coordinator.mac}-{block.description}" # pylint: disable-next=hass-missing-super-call @@ -407,12 +410,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - key, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -526,11 +524,7 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): coordinator.device, None, description.name ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator) self._last_value = None @property @@ -637,12 +631,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.block: Block | None = block # type: ignore[assignment] self.entity_description = description - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, block) if block is not None: self._attr_unique_id = ( @@ -707,15 +696,8 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.attribute = attribute self.entity_description = description - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - key, - suggested_area=coordinator.suggested_area, - ) - self._attr_unique_id = self._attr_unique_id = ( - f"{coordinator.mac}-{key}-{attribute}" - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) + self._attr_unique_id = f"{coordinator.mac}-{key}-{attribute}" self._last_value = None if coordinator.device.initialized: @@ -741,3 +723,37 @@ def get_entity_class( return description.entity_class return sensor_class + + +def get_entity_block_device_info( + coordinator: ShellyBlockCoordinator, + block: Block | None = None, +) -> DeviceInfo: + """Get device info for block entities.""" + return get_block_device_info( + coordinator.device, + coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, + block, + suggested_area=coordinator.suggested_area, + ) + + +def get_entity_rpc_device_info( + coordinator: ShellyRpcCoordinator, + key: str | None = None, + emeter_phase: str | None = None, +) -> DeviceInfo: + """Get device info for RPC entities.""" + return get_rpc_device_info( + coordinator.device, + coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, + key, + emeter_phase=emeter_phase, + suggested_area=coordinator.suggested_area, + ) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 2eb9ff00964..8b2b92e11ce 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -26,12 +26,11 @@ from .const import ( SHIX3_1_INPUTS_EVENTS_TYPES, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyBlockEntity +from .entity import ShellyBlockEntity, get_entity_rpc_device_info from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, - get_rpc_device_info, get_rpc_entity_name, get_rpc_key_instances, is_block_momentary_input, @@ -206,12 +205,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Initialize Shelly entity.""" super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - key, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) self.entity_description = description diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 78e01e6d8a6..78fc8261bfe 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.6.0"], + "requirements": ["aioshelly==13.8.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index c39f619fc6c..e1b15f04417 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -11,7 +11,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -20,9 +20,11 @@ from .const import ( BLE_SCANNER_MIN_FIRMWARE, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from .coordinator import ShellyConfigEntry +from .utils import get_rpc_ws_url @callback @@ -65,7 +67,46 @@ def async_manage_ble_scanner_firmware_unsupported_issue( ir.async_delete_issue(hass, DOMAIN, issue_id) -class BleScannerFirmwareUpdateFlow(RepairsFlow): +@callback +def async_manage_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the Outbound WebSocket incorrectly enabled issue.""" + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format( + unique=entry.unique_id + ) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + + if ( + (ws_config := device.config.get("ws")) + and ws_config["enable"] + and ws_config["server"] == get_rpc_ws_url(hass) + ): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="outbound_websocket_incorrectly_enabled", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +class ShellyRpcRepairsFlow(RepairsFlow): """Handler for an issue fixing flow.""" def __init__(self, device: RpcDevice) -> None: @@ -83,7 +124,7 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - return await self.async_step_update_firmware() + return await self._async_step_confirm() issue_registry = ir.async_get(self.hass) description_placeholders = None @@ -96,6 +137,18 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): description_placeholders=description_placeholders, ) + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + raise NotImplementedError + + +class BleScannerFirmwareUpdateFlow(ShellyRpcRepairsFlow): + """Handler for BLE Scanner Firmware Update flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_update_firmware() + async def async_step_update_firmware( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: @@ -110,6 +163,29 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): return self.async_create_entry(title="", data={}) +class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow): + """Handler for Disable Outbound WebSocket flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_disable_outbound_websocket() + + async def async_step_disable_outbound_websocket( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + try: + result = await self._device.ws_setconfig( + False, self._device.config["ws"]["server"] + ) + if result["restart_required"]: + await self._device.trigger_reboot() + except (DeviceConnectionError, RpcCallError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -124,4 +200,11 @@ async def async_create_fix_flow( assert entry is not None device = entry.runtime_data.rpc.device - return BleScannerFirmwareUpdateFlow(device) + + if "ble_scanner_firmware_unsupported" in issue_id: + return BleScannerFirmwareUpdateFlow(device) + + if "outbound_websocket_incorrectly_enabled" in issue_id: + return DisableOutboundWebSocketFlow(device) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 3a6f5f221c5..49e3d4773c7 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -52,13 +52,13 @@ from .entity import ( async_setup_entry_attribute_entities, async_setup_entry_rest, async_setup_entry_rpc, + get_entity_rpc_device_info, ) from .utils import ( async_remove_orphaned_entities, get_blu_trv_device_info, get_device_entry_gen, get_device_uptime, - get_rpc_device_info, get_shelly_air_lamp_life, get_virtual_component_ids, is_rpc_wifi_stations_disabled, @@ -138,12 +138,8 @@ class RpcEmeterPhaseSensor(RpcSensor): """Initialize select.""" super().__init__(coordinator, key, attribute, description) - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - key, - emeter_phase=description.emeter_phase, - suggested_area=coordinator.suggested_area, + self._attr_device_info = get_entity_rpc_device_info( + coordinator, key, emeter_phase=description.emeter_phase ) @@ -868,8 +864,8 @@ RPC_SENSORS: Final = { native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - available=lambda status: (status and status["n_current"]) is not None, - removal_condition=lambda _config, status, _key: "n_current" not in status, + removal_condition=lambda _config, status, key: status[key].get("n_current") + is None, entity_registry_enabled_default=False, ), "total_current": RpcSensorDescription( diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 28f3a993462..2bb5cd73bfd 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -105,7 +105,7 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner.", - "zigbee_enabled": "Device with Zigbee enabled cannot be used as a Bluetooth scanner. Please disable it to use the device as a Bluetooth scanner." + "zigbee_firmware": "A device with Zigbee firmware cannot be used as a Bluetooth scanner. Please switch to Matter firmware to use the device as a Bluetooth scanner." } }, "selector": { @@ -288,6 +288,20 @@ "unsupported_firmware": { "title": "Unsupported firmware for device {device_name}", "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device." + }, + "outbound_websocket_incorrectly_enabled": { + "title": "Outbound WebSocket is enabled for {device_name}", + "fix_flow": { + "step": { + "confirm": { + "title": "Outbound WebSocket is enabled for {device_name}", + "description": "Your Shelly device {device_name} with IP address {ip_address} is a non-sleeping device and Outbound WebSocket should be disabled in its configuration.\n\nSelect **Submit** button to disable Outbound WebSocket." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } } } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 953fcbace06..2ee960348dd 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -451,7 +451,7 @@ def get_rpc_entity_name( def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" - return entry.data.get(CONF_GEN, 1) + return entry.data.get(CONF_GEN, 1) # type: ignore[no-any-return] def get_rpc_key_instances( @@ -749,6 +749,9 @@ async def get_rpc_scripts_event_types( def get_rpc_device_info( device: RpcDevice, mac: str, + configuration_url: str, + model: str, + model_name: str | None = None, key: str | None = None, emeter_phase: str | None = None, suggested_area: str | None = None, @@ -771,8 +774,11 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}-{emeter_phase.lower()}")}, name=get_rpc_sub_device_name(device, key, emeter_phase), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) if ( @@ -786,8 +792,11 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}")}, name=get_rpc_sub_device_name(device, key), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) @@ -810,6 +819,9 @@ def get_blu_trv_device_info( def get_block_device_info( device: BlockDevice, mac: str, + configuration_url: str, + model: str, + model_name: str | None = None, block: Block | None = None, suggested_area: str | None = None, ) -> DeviceInfo: @@ -826,8 +838,11 @@ def get_block_device_info( identifiers={(DOMAIN, f"{mac}-{block.description}")}, name=get_block_sub_device_name(device, block), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index cee768b6ad0..3e3ee6ef2fa 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==11.2.1", "simplehound==0.3"] + "requirements": ["Pillow==11.3.0", "simplehound==0.3"] } diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 8a75baa69c6..67bf94c61ae 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -573,6 +573,7 @@ class SimpliSafe: self._hass, LOGGER, name=self.entry.title, + config_entry=self.entry, update_interval=DEFAULT_SCAN_INTERVAL, update_method=self.async_update, ) diff --git a/homeassistant/components/sleep_as_android/__init__.py b/homeassistant/components/sleep_as_android/__init__.py new file mode 100644 index 00000000000..8dd08ba0388 --- /dev/null +++ b/homeassistant/components/sleep_as_android/__init__.py @@ -0,0 +1,66 @@ +"""The Sleep as Android integration.""" + +from __future__ import annotations + +from http import HTTPStatus + +from aiohttp.web import Request, Response +import voluptuous as vol + +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ATTR_EVENT, ATTR_VALUE1, ATTR_VALUE2, ATTR_VALUE3, DOMAIN + +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] + +type SleepAsAndroidConfigEntry = ConfigEntry + +WEBHOOK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_EVENT): str, + vol.Optional(ATTR_VALUE1): str, + vol.Optional(ATTR_VALUE2): str, + vol.Optional(ATTR_VALUE3): str, + } +) + + +async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> Response: + """Handle incoming Sleep as Android webhook request.""" + + try: + data = WEBHOOK_SCHEMA(await request.json()) + except vol.MultipleInvalid as error: + return Response( + text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + ) + + async_dispatcher_send(hass, DOMAIN, webhook_id, data) + return Response(status=HTTPStatus.NO_CONTENT) + + +async def async_setup_entry( + hass: HomeAssistant, entry: SleepAsAndroidConfigEntry +) -> bool: + """Set up Sleep as Android from a config entry.""" + + webhook.async_register( + hass, DOMAIN, entry.title, entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SleepAsAndroidConfigEntry +) -> bool: + """Unload a config entry.""" + webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sleep_as_android/config_flow.py b/homeassistant/components/sleep_as_android/config_flow.py new file mode 100644 index 00000000000..595612cc601 --- /dev/null +++ b/homeassistant/components/sleep_as_android/config_flow.py @@ -0,0 +1,14 @@ +"""Config flow for the Sleep as Android integration.""" + +from __future__ import annotations + +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + "Sleep as Android", + {"docs_url": "https://www.home-assistant.io/integrations/sleep_as_android"}, + allow_multiple=True, +) diff --git a/homeassistant/components/sleep_as_android/const.py b/homeassistant/components/sleep_as_android/const.py new file mode 100644 index 00000000000..37cf3f14261 --- /dev/null +++ b/homeassistant/components/sleep_as_android/const.py @@ -0,0 +1,32 @@ +"""Constants for the Sleep as Android integration.""" + +DOMAIN = "sleep_as_android" + +ATTR_EVENT = "event" +ATTR_VALUE1 = "value1" +ATTR_VALUE2 = "value2" +ATTR_VALUE3 = "value3" + +MAP_EVENTS = { + "sleep_tracking_paused": "paused", + "sleep_tracking_resumed": "resumed", + "sleep_tracking_started": "started", + "sleep_tracking_stopped": "stopped", + "alarm_alert_dismiss": "alert_dismiss", + "alarm_alert_start": "alert_start", + "alarm_rescheduled": "rescheduled", + "alarm_skip_next": "skip_next", + "alarm_snooze_canceled": "snooze_canceled", + "alarm_snooze_clicked": "snooze_clicked", + "alarm_wake_up_check": "wake_up_check", + "sound_event_baby": "baby", + "sound_event_cough": "cough", + "sound_event_laugh": "laugh", + "sound_event_snore": "snore", + "sound_event_talk": "talk", + "lullaby_start": "start", + "lullaby_stop": "stop", + "lullaby_volume_down": "volume_down", +} + +ALARM_LABEL_DEFAULT = "alarm" diff --git a/homeassistant/components/sleep_as_android/diagnostics.py b/homeassistant/components/sleep_as_android/diagnostics.py new file mode 100644 index 00000000000..2f49e818ece --- /dev/null +++ b/homeassistant/components/sleep_as_android/diagnostics.py @@ -0,0 +1,19 @@ +"""Diagnostics platform for Sleep as Android integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import SleepAsAndroidConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: SleepAsAndroidConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "config_entry_data": {"cloudhook": config_entry.data["cloudhook"]}, + } diff --git a/homeassistant/components/sleep_as_android/entity.py b/homeassistant/components/sleep_as_android/entity.py new file mode 100644 index 00000000000..5984bb45efd --- /dev/null +++ b/homeassistant/components/sleep_as_android/entity.py @@ -0,0 +1,47 @@ +"""Base entity for Sleep as Android integration.""" + +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity, EntityDescription + +from . import SleepAsAndroidConfigEntry +from .const import DOMAIN + + +class SleepAsAndroidEntity(Entity): + """Base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry: SleepAsAndroidConfigEntry, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{entity_description.key}" + self.entity_description = entity_description + self.webhook_id = config_entry.data[CONF_WEBHOOK_ID] + self._attr_device_info = DeviceInfo( + connections={(DOMAIN, config_entry.entry_id)}, + manufacturer="Urbandroid", + model="Sleep as Android", + name=config_entry.title, + ) + + @abstractmethod + def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: + """Handle the Sleep as Android event.""" + + async def async_added_to_hass(self) -> None: + """Register event callback.""" + + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event) + ) diff --git a/homeassistant/components/sleep_as_android/event.py b/homeassistant/components/sleep_as_android/event.py new file mode 100644 index 00000000000..20a3690a0a5 --- /dev/null +++ b/homeassistant/components/sleep_as_android/event.py @@ -0,0 +1,153 @@ +"""Event platform for Sleep as Android integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SleepAsAndroidConfigEntry +from .const import ATTR_EVENT, MAP_EVENTS +from .entity import SleepAsAndroidEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class SleepAsAndroidEventEntityDescription(EventEntityDescription): + """Sleep as Android sensor description.""" + + event_types: list[str] + + +class SleepAsAndroidEvent(StrEnum): + """Sleep as Android events.""" + + ALARM_CLOCK = "alarm_clock" + USER_NOTIFICATION = "user_notification" + SMART_WAKEUP = "smart_wakeup" + SLEEP_HEALTH = "sleep_health" + LULLABY = "lullaby" + SLEEP_PHASE = "sleep_phase" + SLEEP_TRACKING = "sleep_tracking" + SOUND_EVENT = "sound_event" + + +EVENT_DESCRIPTIONS: tuple[SleepAsAndroidEventEntityDescription, ...] = ( + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_TRACKING, + translation_key=SleepAsAndroidEvent.SLEEP_TRACKING, + device_class=EventDeviceClass.BUTTON, + event_types=[ + "paused", + "resumed", + "started", + "stopped", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.ALARM_CLOCK, + translation_key=SleepAsAndroidEvent.ALARM_CLOCK, + event_types=[ + "alert_dismiss", + "alert_start", + "rescheduled", + "skip_next", + "snooze_canceled", + "snooze_clicked", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SMART_WAKEUP, + translation_key=SleepAsAndroidEvent.SMART_WAKEUP, + event_types=[ + "before_smart_period", + "smart_period", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.USER_NOTIFICATION, + translation_key=SleepAsAndroidEvent.USER_NOTIFICATION, + event_types=[ + "wake_up_check", + "show_skip_next_alarm", + "time_to_bed_alarm_alert", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_PHASE, + translation_key=SleepAsAndroidEvent.SLEEP_PHASE, + event_types=[ + "awake", + "deep_sleep", + "light_sleep", + "not_awake", + "rem", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SOUND_EVENT, + translation_key=SleepAsAndroidEvent.SOUND_EVENT, + event_types=[ + "baby", + "cough", + "laugh", + "snore", + "talk", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.LULLABY, + translation_key=SleepAsAndroidEvent.LULLABY, + event_types=[ + "start", + "stop", + "volume_down", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_HEALTH, + translation_key=SleepAsAndroidEvent.SLEEP_HEALTH, + event_types=[ + "antisnoring", + "apnea_alarm", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SleepAsAndroidConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the event platform.""" + + async_add_entities( + SleepAsAndroidEventEntity(config_entry, description) + for description in EVENT_DESCRIPTIONS + ) + + +class SleepAsAndroidEventEntity(SleepAsAndroidEntity, EventEntity): + """An event entity.""" + + entity_description: SleepAsAndroidEventEntityDescription + + @callback + def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: + """Handle the Sleep as Android event.""" + event = MAP_EVENTS.get(data[ATTR_EVENT], data[ATTR_EVENT]) + if ( + webhook_id == self.webhook_id + and event in self.entity_description.event_types + ): + self._trigger_event(event) + self.async_write_ha_state() diff --git a/homeassistant/components/sleep_as_android/icons.json b/homeassistant/components/sleep_as_android/icons.json new file mode 100644 index 00000000000..0565716a5f1 --- /dev/null +++ b/homeassistant/components/sleep_as_android/icons.json @@ -0,0 +1,38 @@ +{ + "entity": { + "event": { + "alarm_clock": { + "default": "mdi:alarm" + }, + "user_notification": { + "default": "mdi:cellphone-message" + }, + "smart_wakeup": { + "default": "mdi:brain" + }, + "sleep_phase": { + "default": "mdi:bed" + }, + "sound_event": { + "default": "mdi:chat-sleep-outline" + }, + "sleep_tracking": { + "default": "mdi:record-rec" + }, + "lullaby": { + "default": "mdi:cradle-outline" + }, + "sleep_health": { + "default": "mdi:heart-pulse" + } + }, + "sensor": { + "alarm_time": { + "default": "mdi:alarm" + }, + "alarm_label": { + "default": "mdi:label-outline" + } + } + } +} diff --git a/homeassistant/components/sleep_as_android/manifest.json b/homeassistant/components/sleep_as_android/manifest.json new file mode 100644 index 00000000000..fbac134ffa1 --- /dev/null +++ b/homeassistant/components/sleep_as_android/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sleep_as_android", + "name": "Sleep as Android", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "dependencies": ["webhook"], + "documentation": "https://www.home-assistant.io/integrations/sleep_as_android", + "iot_class": "local_push", + "quality_scale": "silver" +} diff --git a/homeassistant/components/sleep_as_android/quality_scale.yaml b/homeassistant/components/sleep_as_android/quality_scale.yaml new file mode 100644 index 00000000000..acc2d8d11f0 --- /dev/null +++ b/homeassistant/components/sleep_as_android/quality_scale.yaml @@ -0,0 +1,110 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: has no actions + appropriate-polling: + status: exempt + comment: does not poll + brands: done + common-modules: done + config-flow-test-coverage: + status: done + comment: uses webhook flow helper, already covered + config-flow: done + dependency-transparency: + status: exempt + comment: no dependencies + docs-actions: + status: exempt + comment: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: has no runtime data + test-before-configure: + status: exempt + comment: nothing to test + test-before-setup: + status: exempt + comment: nothing to test + unique-config-entry: + status: exempt + comment: only 1 webhook can be configured per device. It's not possible to prevent different devices from using the same webhook + + # Silver + action-exceptions: + status: exempt + comment: no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: only state-less entities + integration-owner: done + log-when-unavailable: + status: exempt + comment: only state-less entities + parallel-updates: done + reauthentication-flow: + status: exempt + comment: no authentication required + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: no discovery + discovery: + status: exempt + comment: cannot be discovered + docs-data-update: + status: exempt + comment: does not poll + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: has no devices + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: has no devices + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: does not raise exceptions + icon-translations: done + reconfiguration-flow: + status: exempt + comment: webhook config flow helper does not implement reconfigure + repair-issues: + status: exempt + comment: has no repairs + stale-devices: + status: exempt + comment: has no stale devices + + # Platinum + async-dependency: + status: exempt + comment: has no external dependencies + inject-websession: + status: exempt + comment: does not do http requests + strict-typing: done diff --git a/homeassistant/components/sleep_as_android/sensor.py b/homeassistant/components/sleep_as_android/sensor.py new file mode 100644 index 00000000000..966e851f633 --- /dev/null +++ b/homeassistant/components/sleep_as_android/sensor.py @@ -0,0 +1,96 @@ +"""Sensor platform for Sleep as Android integration.""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from . import SleepAsAndroidConfigEntry +from .const import ALARM_LABEL_DEFAULT, ATTR_EVENT, ATTR_VALUE1, ATTR_VALUE2 +from .entity import SleepAsAndroidEntity + +PARALLEL_UPDATES = 0 + + +class SleepAsAndroidSensor(StrEnum): + """Sleep as Android sensors.""" + + NEXT_ALARM = "next_alarm" + ALARM_LABEL = "alarm_label" + + +SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SleepAsAndroidSensor.NEXT_ALARM, + translation_key=SleepAsAndroidSensor.NEXT_ALARM, + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key=SleepAsAndroidSensor.ALARM_LABEL, + translation_key=SleepAsAndroidSensor.ALARM_LABEL, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SleepAsAndroidConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + + async_add_entities( + SleepAsAndroidSensorEntity(config_entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class SleepAsAndroidSensorEntity(SleepAsAndroidEntity, RestoreSensor): + """A sensor entity.""" + + entity_description: SensorEntityDescription + + @callback + def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: + """Handle the Sleep as Android event.""" + + if webhook_id == self.webhook_id and data[ATTR_EVENT] in ( + "alarm_snooze_clicked", + "alarm_snooze_canceled", + "alarm_alert_start", + "alarm_alert_dismiss", + "alarm_skip_next", + "show_skip_next_alarm", + "alarm_rescheduled", + ): + if ( + self.entity_description.key is SleepAsAndroidSensor.NEXT_ALARM + and (alarm_time := data.get(ATTR_VALUE1)) + and alarm_time.isnumeric() + ): + self._attr_native_value = datetime.fromtimestamp( + int(alarm_time) / 1000, tz=dt_util.get_default_time_zone() + ) + if self.entity_description.key is SleepAsAndroidSensor.ALARM_LABEL and ( + label := data.get(ATTR_VALUE2, ALARM_LABEL_DEFAULT) + ): + self._attr_native_value = label + + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore entity state.""" + state = await self.async_get_last_sensor_data() + if state: + self._attr_native_value = state.native_value + + await super().async_added_to_hass() diff --git a/homeassistant/components/sleep_as_android/strings.json b/homeassistant/components/sleep_as_android/strings.json new file mode 100644 index 00000000000..f36b26e5b58 --- /dev/null +++ b/homeassistant/components/sleep_as_android/strings.json @@ -0,0 +1,134 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Sleep as Android", + "description": "Are you sure you want to set up the Sleep as Android integration?" + } + }, + "abort": { + "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "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." + } + }, + "entity": { + "event": { + "sleep_tracking": { + "name": "Sleep tracking", + "state_attributes": { + "event_type": { + "state": { + "paused": "[%key:common::state::paused%]", + "resumed": "Resumed", + "started": "Started", + "stopped": "[%key:common::state::stopped%]" + } + } + } + }, + "alarm_clock": { + "name": "Alarm clock", + "state_attributes": { + "event_type": { + "state": { + "alert_dismiss": "Alarm dismissed", + "alert_start": "Alarm started", + "rescheduled": "Alarm rescheduled", + "skip_next": "Alarm skipped", + "snooze_canceled": "Snooze canceled", + "snooze_clicked": "Snoozing" + } + } + } + }, + "smart_wakeup": { + "name": "Smart wake-up", + "state_attributes": { + "event_type": { + "state": { + "before_smart_period": "45min before smart wake-up", + "smart_period": "Smart wake-up started" + } + } + } + }, + "user_notification": { + "name": "User notification", + "state_attributes": { + "event_type": { + "state": { + "wake_up_check": "Wake-up check", + "show_skip_next_alarm": "Skip next alarm", + "time_to_bed_alarm_alert": "Time to bed" + } + } + } + }, + "sleep_phase": { + "name": "Sleep phase", + "state_attributes": { + "event_type": { + "state": { + "awake": "Woke up", + "deep_sleep": "Deep sleep", + "light_sleep": "Light sleep", + "not_awake": "Fell asleep", + "rem": "REM sleep" + } + } + } + }, + "sound_event": { + "name": "Sound recognition", + "state_attributes": { + "event_type": { + "state": { + "baby": "Baby crying", + "cough": "Coughing or sneezing", + "laugh": "Laughter", + "snore": "Snoring", + "talk": "Talking" + } + } + } + }, + "lullaby": { + "name": "Lullaby", + "state_attributes": { + "event_type": { + "state": { + "start": "Started", + "stop": "[%key:common::state::stopped%]", + "volume_down": "Lowering volume" + } + } + } + }, + "sleep_health": { + "name": "Sleep health", + "state_attributes": { + "event_type": { + "state": { + "antisnoring": "Anti-snoring triggered", + "apnea_alarm": "Sleep apnea detected" + } + } + } + } + }, + "sensor": { + "next_alarm": { + "name": "Next alarm" + }, + "alarm_label": { + "name": "Alarm label", + "state": { + "alarm": "Alarm" + } + } + } + } +} diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4243684cd52..7a9415bac20 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -4,6 +4,8 @@ DATA_SLEEPIQ = "data_sleepiq" DOMAIN = "sleepiq" ACTUATOR = "actuator" +CORE_CLIMATE_TIMER = "core_climate_timer" +CORE_CLIMATE = "core_climate" BED = "bed" FIRMNESS = "firmness" ICON_EMPTY = "mdi:bed-empty" @@ -15,6 +17,8 @@ FOOT_WARMING_TIMER = "foot_warming_timer" FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", + CORE_CLIMATE_TIMER: "Core Climate Timer", + CORE_CLIMATE: "Core Climate", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index db29e5ab586..dd2e05ee3ba 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.5.2"] + "requirements": ["asyncsleepiq==1.6.0"] } diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 53d6c366e46..1a99f47c38c 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -7,20 +7,28 @@ from dataclasses import dataclass from typing import Any, cast from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQSleeper, ) -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, + CORE_CLIMATE_TIMER, DOMAIN, ENTITY_TYPES, FIRMNESS, @@ -95,6 +103,27 @@ def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" +async def _async_set_core_climate_time( + core_climate: SleepIQCoreClimate, time: int +) -> None: + temperature = CoreTemps(core_climate.temperature) + if temperature != CoreTemps.OFF: + await core_climate.turn_on(temperature, time) + + core_climate.timer = time + + +def _get_core_climate_name(bed: SleepIQBed, core_climate: SleepIQCoreClimate) -> str: + sleeper = sleeper_for_side(bed, core_climate.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[CORE_CLIMATE_TIMER]}" + + +def _get_core_climate_unique_id( + bed: SleepIQBed, core_climate: SleepIQCoreClimate +) -> str: + return f"{bed.id}_{core_climate.side.value}_{CORE_CLIMATE_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -132,6 +161,20 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { get_name_fn=_get_foot_warming_name, get_unique_id_fn=_get_foot_warming_unique_id, ), + CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( + key=CORE_CLIMATE_TIMER, + native_min_value=0, + native_max_value=SleepIQCoreClimate.max_core_climate_time, + native_step=30, + name=ENTITY_TYPES[CORE_CLIMATE_TIMER], + icon="mdi:timer", + value_fn=lambda core_climate: core_climate.timer, + set_value_fn=_async_set_core_climate_time, + get_name_fn=_get_core_climate_name, + get_unique_id_fn=_get_core_climate_unique_id, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + ), } @@ -172,6 +215,15 @@ async def async_setup_entry( ) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQNumberEntity( + data.data_coordinator, + bed, + core_climate, + NUMBER_DESCRIPTIONS[CORE_CLIMATE_TIMER], + ) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 7d059ba6b59..d4bc9fda3a4 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -3,9 +3,11 @@ from __future__ import annotations from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, Side, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQPreset, ) @@ -15,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, FOOT_WARMER +from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side @@ -37,6 +39,10 @@ async def async_setup_entry( SleepIQFootWarmingTempSelectEntity(data.data_coordinator, bed, foot_warmer) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQCoreTempSelectEntity(data.data_coordinator, bed, core_climate) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) @@ -115,3 +121,57 @@ class SleepIQFootWarmingTempSelectEntity( self._attr_current_option = option await self.coordinator.async_request_refresh() self.async_write_ha_state() + + +class SleepIQCoreTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ core climate temperature select entity.""" + + # Maps to translate between asyncsleepiq and HA's naming preference + SLEEPIQ_TO_HA_CORE_TEMP_MAP = { + CoreTemps.OFF: "off", + CoreTemps.HEATING_PUSH_LOW: "heating_low", + CoreTemps.HEATING_PUSH_MED: "heating_medium", + CoreTemps.HEATING_PUSH_HIGH: "heating_high", + CoreTemps.COOLING_PULL_LOW: "cooling_low", + CoreTemps.COOLING_PULL_MED: "cooling_medium", + CoreTemps.COOLING_PULL_HIGH: "cooling_high", + } + HA_TO_SLEEPIQ_CORE_TEMP_MAP = {v: k for k, v in SLEEPIQ_TO_HA_CORE_TEMP_MAP.items()} + + _attr_icon = "mdi:heat-wave" + _attr_options = list(SLEEPIQ_TO_HA_CORE_TEMP_MAP.values()) + _attr_translation_key = "core_temps" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + core_climate: SleepIQCoreClimate, + ) -> None: + """Initialize the select entity.""" + self.core_climate = core_climate + sleeper = sleeper_for_side(bed, core_climate.side) + super().__init__(coordinator, bed, sleeper, CORE_CLIMATE) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + sleepiq_option = CoreTemps(self.core_climate.temperature) + self._attr_current_option = self.SLEEPIQ_TO_HA_CORE_TEMP_MAP[sleepiq_option] + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = self.HA_TO_SLEEPIQ_CORE_TEMP_MAP[option] + timer = self.core_climate.timer or 240 + + if temperature == CoreTemps.OFF: + await self.core_climate.turn_off() + else: + await self.core_climate.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 634202d6da8..58a35ea914b 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -33,6 +33,17 @@ "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" } + }, + "core_temps": { + "state": { + "off": "[%key:common::state::off%]", + "heating_low": "Heating low", + "heating_medium": "Heating medium", + "heating_high": "Heating high", + "cooling_low": "Cooling low", + "cooling_medium": "Cooling medium", + "cooling_high": "Cooling high" + } } } } diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 4690fe8016c..7d2027a985a 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -21,16 +21,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True -async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 96aac1a135c..7593d502bec 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,7 +14,11 @@ from goslideapi.goslideapi import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -232,7 +236,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SlideOptionsFlowHandler(OptionsFlow): +class SlideOptionsFlowHandler(OptionsFlowWithReload): """Handle a options flow for slide_local.""" async def async_step_init( diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index f43c851d04a..e08b9ade9fc 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -184,7 +184,36 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): self._data[CONF_HOST] = discovery_info.ip self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) - await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + _LOGGER.debug( + "DHCP discovery detected SMA device: %s, IP: %s, MAC: %s", + self._discovery_data[CONF_NAME], + self._discovery_data[CONF_HOST], + self._discovery_data[CONF_MAC], + ) + + existing_entries_with_host = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.data.get(CONF_HOST) == self._data[CONF_HOST] + and not entry.data.get(CONF_MAC) + ] + + # If we have an existing entry with the same host but no MAC address, + # we update the entry with the MAC address and reload it. + if existing_entries_with_host: + entry = existing_entries_with_host[0] + self.async_update_reload_and_abort( + entry, data_updates={CONF_MAC: self._data[CONF_MAC]} + ) + + # Finally, check if the hostname (which represents the SMA serial number) is unique + serial_number = discovery_info.hostname.lower() + # Example hostname: sma12345678-01 + # Remove 'sma' prefix and strip everything after the dash (including the dash) + if serial_number.startswith("sma"): + serial_number = serial_number.removeprefix("sma") + serial_number = serial_number.split("-", 1)[0] + await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured() return await self.async_step_discovery_confirm() diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index 2de3fcfa242..533acb3375b 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -5,7 +5,7 @@ from pysmarlaapi import Connection, Federwiege from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryError from .const import HOST, PLATFORMS @@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - # Check if token still has access if not await connection.refresh_token(): - raise ConfigEntryAuthFailed("Invalid authentication") + raise ConfigEntryError("Invalid authentication") federwiege = Federwiege(hass.loop, connection) federwiege.register() diff --git a/homeassistant/components/smarla/const.py b/homeassistant/components/smarla/const.py index f81ccd328bc..fcb64f1e315 100644 --- a/homeassistant/components/smarla/const.py +++ b/homeassistant/components/smarla/const.py @@ -6,7 +6,7 @@ DOMAIN = "smarla" HOST = "https://devices.swing2sleep.de" -PLATFORMS = [Platform.NUMBER, Platform.SWITCH] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] DEVICE_MODEL_NAME = "Smarla" MANUFACTURER_NAME = "Swing2Sleep" diff --git a/homeassistant/components/smarla/icons.json b/homeassistant/components/smarla/icons.json index 2ba7404cc35..a72e7e7ea12 100644 --- a/homeassistant/components/smarla/icons.json +++ b/homeassistant/components/smarla/icons.json @@ -9,6 +9,20 @@ "intensity": { "default": "mdi:sine-wave" } + }, + "sensor": { + "amplitude": { + "default": "mdi:sine-wave" + }, + "period": { + "default": "mdi:sine-wave" + }, + "activity": { + "default": "mdi:baby-face" + }, + "swing_count": { + "default": "mdi:counter" + } } } } diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index 8f7786bdf72..a99cf9b4891 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.9.0"] + "requirements": ["pysmarlaapi==0.9.2"] } diff --git a/homeassistant/components/smarla/sensor.py b/homeassistant/components/smarla/sensor.py new file mode 100644 index 00000000000..18bef76e320 --- /dev/null +++ b/homeassistant/components/smarla/sensor.py @@ -0,0 +1,107 @@ +"""Support for the Swing2Sleep Smarla sensor entities.""" + +from dataclasses import dataclass + +from pysmarlaapi.federwiege.classes import Property + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FederwiegeConfigEntry +from .entity import SmarlaBaseEntity, SmarlaEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class SmarlaSensorEntityDescription(SmarlaEntityDescription, SensorEntityDescription): + """Class describing Swing2Sleep Smarla sensor entities.""" + + multiple: bool = False + value_pos: int = 0 + + +SENSORS: list[SmarlaSensorEntityDescription] = [ + SmarlaSensorEntityDescription( + key="amplitude", + translation_key="amplitude", + service="analyser", + property="oscillation", + multiple=True, + value_pos=0, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="period", + translation_key="period", + service="analyser", + property="oscillation", + multiple=True, + value_pos=1, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="activity", + translation_key="activity", + service="analyser", + property="activity", + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="swing_count", + translation_key="swing_count", + service="analyser", + property="swing_count", + state_class=SensorStateClass.TOTAL_INCREASING, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FederwiegeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> 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 + ) + + +class SmarlaSensor(SmarlaBaseEntity, SensorEntity): + """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 + + _property: Property[list[int]] + + @property + def native_value(self) -> int | None: + """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 diff --git a/homeassistant/components/smarla/strings.json b/homeassistant/components/smarla/strings.json index fbe5df4c1d0..edf306b1183 100644 --- a/homeassistant/components/smarla/strings.json +++ b/homeassistant/components/smarla/strings.json @@ -28,6 +28,21 @@ "intensity": { "name": "Intensity" } + }, + "sensor": { + "amplitude": { + "name": "Amplitude" + }, + "period": { + "name": "Period" + }, + "activity": { + "name": "Activity" + }, + "swing_count": { + "name": "Swing count", + "unit_of_measurement": "swings" + } } } } diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e4259e4182c..9c7621037c7 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -103,6 +103,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VACUUM, Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 7c3fc47e512..951d1372a69 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.5"] + "requirements": ["pysmartthings==3.2.9"] } diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 99dc7a09f87..3106aba5e49 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -18,6 +18,11 @@ from .entity import SmartThingsEntity LAMP_TO_HA = { "extraHigh": "extra_high", + "high": "high", + "mid": "mid", + "low": "low", + "on": "on", + "off": "off", } WASHER_SOIL_LEVEL_TO_HA = { diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index a38331d6aed..d3e2ab09a3f 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -476,6 +476,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.FINE_DUST_SENSOR: { + Attribute.FINE_DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.FINE_DUST_LEVEL, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM25, + ) + ] + }, # Haven't seen at devices yet Capability.FORMALDEHYDE_MEASUREMENT: { Attribute.FORMALDEHYDE_LEVEL: [ diff --git a/homeassistant/components/smartthings/vacuum.py b/homeassistant/components/smartthings/vacuum.py new file mode 100644 index 00000000000..59152842150 --- /dev/null +++ b/homeassistant/components/smartthings/vacuum.py @@ -0,0 +1,95 @@ +"""SmartThings vacuum platform.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pysmartthings import Attribute, Command, SmartThings +from pysmartthings.capability import Capability + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up vacuum entities from SmartThings devices.""" + entry_data = entry.runtime_data + async_add_entities( + SamsungJetBotVacuum(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE in device.status[MAIN] + ) + + +class SamsungJetBotVacuum(SmartThingsEntity, StateVacuumEntity): + """Representation of a Vacuum.""" + + _attr_name = None + _attr_supported_features = ( + VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STATE + ) + + 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}, + ) + + @property + def activity(self) -> VacuumActivity | None: + """Return the current vacuum activity based on operating state.""" + status = self.get_attribute_value( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + ) + + return { + "cleaning": VacuumActivity.CLEANING, + "homing": VacuumActivity.RETURNING, + "idle": VacuumActivity.IDLE, + "paused": VacuumActivity.PAUSED, + "docked": VacuumActivity.DOCKED, + "error": VacuumActivity.ERROR, + "charging": VacuumActivity.DOCKED, + }.get(status) + + async def async_start(self) -> None: + """Start the vacuum's operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.START, + ) + + async def async_pause(self) -> None: + """Pause the vacuum's current operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, Command.PAUSE + ) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return the vacuum to its base.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.RETURN_TO_HOME, + ) diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index a120650e84b..1a329ce8a25 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from smarttub import Spa, SpaError, SpaReminder @@ -17,9 +18,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTR_ERRORS, ATTR_REMINDERS +from .const import ATTR_ERRORS, ATTR_REMINDERS, ATTR_SENSORS from .controller import SmartTubConfigEntry -from .entity import SmartTubEntity, SmartTubSensorBase +from .entity import ( + SmartTubEntity, + SmartTubExternalSensorBase, + SmartTubOnboardSensorBase, +) # whether the reminder has been snoozed (bool) ATTR_REMINDER_SNOOZED = "snoozed" @@ -44,6 +49,8 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = { ) } +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -62,6 +69,12 @@ async def async_setup_entry( SmartTubReminder(controller.coordinator, spa, reminder) for reminder in controller.coordinator.data[spa.id][ATTR_REMINDERS].values() ) + for sensor in controller.coordinator.data[spa.id][ATTR_SENSORS].values(): + name = sensor.name.strip("{}") + if name.startswith("cover-"): + entities.append( + SmartTubCoverSensor(controller.coordinator, spa, sensor) + ) async_add_entities(entities) @@ -79,7 +92,7 @@ async def async_setup_entry( ) -class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): +class SmartTubOnline(SmartTubOnboardSensorBase, BinarySensorEntity): """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @@ -192,3 +205,16 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity): ATTR_CREATED_AT: error.created_at.isoformat(), ATTR_UPDATED_AT: error.updated_at.isoformat(), } + + +class SmartTubCoverSensor(SmartTubExternalSensorBase, BinarySensorEntity): + """Wireless magnetic cover sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool: + """Return False if the cover is closed, True if open.""" + # magnet is True when the cover is closed, False when open + # device class OPENING wants True to mean open, False to mean closed + return not self.sensor.magnet diff --git a/homeassistant/components/smarttub/const.py b/homeassistant/components/smarttub/const.py index dadc66da942..8bf9da281a9 100644 --- a/homeassistant/components/smarttub/const.py +++ b/homeassistant/components/smarttub/const.py @@ -24,3 +24,4 @@ ATTR_LIGHTS = "lights" ATTR_PUMPS = "pumps" ATTR_REMINDERS = "reminders" ATTR_STATUS = "status" +ATTR_SENSORS = "sensors" diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index d8299bbd786..095179d618a 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -22,6 +22,7 @@ from .const import ( ATTR_LIGHTS, ATTR_PUMPS, ATTR_REMINDERS, + ATTR_SENSORS, ATTR_STATUS, DOMAIN, POLLING_TIMEOUT, @@ -73,6 +74,7 @@ class SmartTubController: self._hass, _LOGGER, name=DOMAIN, + config_entry=entry, update_method=self.async_update_data, update_interval=timedelta(seconds=SCAN_INTERVAL), ) @@ -108,6 +110,7 @@ class SmartTubController: ATTR_LIGHTS: {light.zone: light for light in full_status.lights}, ATTR_REMINDERS: {reminder.id: reminder for reminder in reminders}, ATTR_ERRORS: errors, + ATTR_SENSORS: {sensor.address: sensor for sensor in full_status.sensors}, } @callback diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 069fd50c5f2..53562fd887a 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -2,7 +2,7 @@ from typing import Any -from smarttub import Spa, SpaState +from smarttub import Spa, SpaSensor, SpaState from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN +from .const import ATTR_SENSORS, DOMAIN from .helpers import get_spa_name @@ -47,8 +47,8 @@ class SmartTubEntity(CoordinatorEntity): return self.coordinator.data[self.spa.id].get("status") -class SmartTubSensorBase(SmartTubEntity): - """Base class for SmartTub sensors.""" +class SmartTubOnboardSensorBase(SmartTubEntity): + """Base class for SmartTub onboard sensors.""" def __init__( self, @@ -65,3 +65,29 @@ class SmartTubSensorBase(SmartTubEntity): def _state(self): """Retrieve the underlying state from the spa.""" return getattr(self.spa_status, self._state_key) + + +class SmartTubExternalSensorBase(SmartTubEntity): + """Class for additional BLE wireless sensors sold separately.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: Spa, + sensor: SpaSensor, + ) -> None: + """Initialize the external sensor entity.""" + self.sensor_address = sensor.address + self._attr_unique_id = f"{spa.id}-externalsensor-{sensor.address}" + super().__init__(coordinator, spa, self._human_readable_name(sensor)) + + @staticmethod + def _human_readable_name(sensor: SpaSensor) -> str: + return " ".join( + word.capitalize() for word in sensor.name.strip("{}").split("-") + ) + + @property + def sensor(self) -> SpaSensor: + """Convenience property to access the smarttub.SpaSensor instance for this sensor.""" + return self.coordinator.data[self.spa.id][ATTR_SENSORS][self.sensor_address] diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index b8d81db0ea5..086446c4c66 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "requirements": ["python-smarttub==0.0.39"] + "requirements": ["python-smarttub==0.0.44"] } diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 5116bfb3aee..64e5eec1f46 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .controller import SmartTubConfigEntry -from .entity import SmartTubSensorBase +from .entity import SmartTubOnboardSensorBase # the desired duration, in hours, of the cycle ATTR_DURATION = "duration" @@ -56,16 +56,16 @@ async def async_setup_entry( for spa in controller.spas: entities.extend( [ - SmartTubSensor(controller.coordinator, spa, "State", "state"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "State", "state"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Flow Switch", "flow_switch" ), - SmartTubSensor(controller.coordinator, spa, "Ozone", "ozone"), - SmartTubSensor(controller.coordinator, spa, "UV", "uv"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "Ozone", "ozone"), + SmartTubBuiltinSensor(controller.coordinator, spa, "UV", "uv"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Blowout Cycle", "blowout_cycle" ), - SmartTubSensor( + SmartTubBuiltinSensor( controller.coordinator, spa, "Cleanup Cycle", "cleanup_cycle" ), SmartTubPrimaryFiltrationCycle(controller.coordinator, spa), @@ -90,7 +90,7 @@ async def async_setup_entry( ) -class SmartTubSensor(SmartTubSensorBase, SensorEntity): +class SmartTubBuiltinSensor(SmartTubOnboardSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property @@ -105,7 +105,7 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): return self._state.lower() -class SmartTubPrimaryFiltrationCycle(SmartTubSensor): +class SmartTubPrimaryFiltrationCycle(SmartTubBuiltinSensor): """The primary filtration cycle.""" def __init__( @@ -145,7 +145,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): await self.coordinator.async_request_refresh() -class SmartTubSecondaryFiltrationCycle(SmartTubSensor): +class SmartTubSecondaryFiltrationCycle(SmartTubBuiltinSensor): """The secondary filtration cycle.""" def __init__( diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 1869b333071..085cbdcbbce 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator -PLATFORMS = [Platform.WEATHER] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index 511ba8b38d9..d8e85917db5 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -24,6 +24,7 @@ class SMHIForecastData: daily: list[SMHIForecast] hourly: list[SMHIForecast] + twice_daily: list[SMHIForecast] class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): @@ -52,6 +53,9 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): async with asyncio.timeout(TIMEOUT): _forecast_daily = await self._smhi_api.async_get_daily_forecast() _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + _forecast_twice_daily = ( + await self._smhi_api.async_get_twice_daily_forecast() + ) except SmhiForecastException as ex: raise UpdateFailed( "Failed to retrieve the forecast from the SMHI API" @@ -60,4 +64,10 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): return SMHIForecastData( daily=_forecast_daily, hourly=_forecast_hourly, + twice_daily=_forecast_twice_daily, ) + + @property + def current(self) -> SMHIForecast: + """Return the current metrics.""" + return self.data.daily[0] diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 89dca3360ca..fb565a7fc51 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -16,7 +17,6 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): _attr_attribution = "Swedish weather institute (SMHI)" _attr_has_entity_name = True - _attr_name = None def __init__( self, @@ -36,6 +36,12 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): ) self.update_entity_data() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() + @abstractmethod def update_entity_data(self) -> None: """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/icons.json b/homeassistant/components/smhi/icons.json new file mode 100644 index 00000000000..5c62b8f03b4 --- /dev/null +++ b/homeassistant/components/smhi/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "thunder": { + "default": "mdi:lightning-bolt" + }, + "total_cloud": { + "default": "mdi:cloud" + }, + "low_cloud": { + "default": "mdi:cloud-arrow-down" + }, + "medium_cloud": { + "default": "mdi:cloud-arrow-right" + }, + "high_cloud": { + "default": "mdi:cloud-arrow-up" + }, + "precipitation_category": { + "default": "mdi:weather-pouring" + }, + "frozen_precipitation": { + "default": "mdi:weather-snowy-rainy" + } + } + } +} diff --git a/homeassistant/components/smhi/sensor.py b/homeassistant/components/smhi/sensor.py new file mode 100644 index 00000000000..bba207c0f09 --- /dev/null +++ b/homeassistant/components/smhi/sensor.py @@ -0,0 +1,139 @@ +"""Sensor platform for SMHI integration.""" + +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.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator +from .entity import SmhiWeatherBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_percentage_values(entity: SMHISensor, key: str) -> int | None: + """Return percentage values in correct range.""" + value: int | None = entity.coordinator.current.get(key) # type: ignore[assignment] + if value is not None and 0 <= value <= 100: + return value + if value is not None: + return 0 + return None + + +@dataclass(frozen=True, kw_only=True) +class SMHISensorEntityDescription(SensorEntityDescription): + """Describes SMHI sensor entity.""" + + value_fn: Callable[[SMHISensor], StateType | datetime] + + +SENSOR_DESCRIPTIONS: tuple[SMHISensorEntityDescription, ...] = ( + SMHISensorEntityDescription( + key="thunder", + translation_key="thunder", + value_fn=lambda entity: get_percentage_values(entity, "thunder"), + native_unit_of_measurement=PERCENTAGE, + ), + SMHISensorEntityDescription( + key="total_cloud", + translation_key="total_cloud", + value_fn=lambda entity: get_percentage_values(entity, "total_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="low_cloud", + translation_key="low_cloud", + value_fn=lambda entity: get_percentage_values(entity, "low_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="medium_cloud", + translation_key="medium_cloud", + value_fn=lambda entity: get_percentage_values(entity, "medium_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="high_cloud", + translation_key="high_cloud", + value_fn=lambda entity: get_percentage_values(entity, "high_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="precipitation_category", + translation_key="precipitation_category", + value_fn=lambda entity: str( + get_percentage_values(entity, "precipitation_category") + ), + device_class=SensorDeviceClass.ENUM, + options=["0", "1", "2", "3", "4", "5", "6"], + ), + SMHISensorEntityDescription( + key="frozen_precipitation", + translation_key="frozen_precipitation", + value_fn=lambda entity: get_percentage_values(entity, "frozen_precipitation"), + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SMHIConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SMHI sensor platform.""" + + coordinator = entry.runtime_data + location = entry.data + async_add_entities( + SMHISensor( + location[CONF_LOCATION][CONF_LATITUDE], + location[CONF_LOCATION][CONF_LONGITUDE], + coordinator=coordinator, + entity_description=description, + ) + for description in SENSOR_DESCRIPTIONS + ) + + +class SMHISensor(SmhiWeatherBaseEntity, SensorEntity): + """Representation of a SMHI Sensor.""" + + entity_description: SMHISensorEntityDescription + + def __init__( + self, + latitude: str, + longitude: str, + coordinator: SMHIDataUpdateCoordinator, + entity_description: SMHISensorEntityDescription, + ) -> None: + """Initiate SMHI Sensor.""" + self.entity_description = entity_description + super().__init__( + latitude, + longitude, + coordinator, + ) + self._attr_unique_id = f"{latitude}, {longitude}-{entity_description.key}" + + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if self.coordinator.data.daily: + self._attr_native_value = self.entity_description.value_fn(self) diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json index 3d2a790e6b6..b6c8f2049fe 100644 --- a/homeassistant/components/smhi/strings.json +++ b/homeassistant/components/smhi/strings.json @@ -23,5 +23,39 @@ "error": { "wrong_location": "Location Sweden only" } + }, + "entity": { + "sensor": { + "thunder": { + "name": "Thunder probability" + }, + "total_cloud": { + "name": "Total cloud coverage" + }, + "low_cloud": { + "name": "Low cloud coverage" + }, + "medium_cloud": { + "name": "Medium cloud coverage" + }, + "high_cloud": { + "name": "High cloud coverage" + }, + "precipitation_category": { + "name": "Precipitation category", + "state": { + "0": "No precipitation", + "1": "Snow", + "2": "Snow and rain", + "3": "Rain", + "4": "Drizzle", + "5": "Freezing rain", + "6": "Freezing drizzle" + } + }, + "frozen_precipitation": { + "name": "Frozen precipitation" + } + } } } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 5faef04e03d..9496321b8b4 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -26,6 +26,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -109,8 +110,11 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_native_pressure_unit = UnitOfPressure.HPA _attr_supported_features = ( - WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY ) + _attr_name = None def update_entity_data(self) -> None: """Refresh the entity data.""" @@ -145,7 +149,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): super()._handle_coordinator_update() def _get_forecast_data( - self, forecast_data: list[SMHIForecast] | None + self, forecast_data: list[SMHIForecast] | None, forecast_type: str ) -> list[Forecast] | None: """Get forecast data.""" if forecast_data is None or len(forecast_data) < 3: @@ -160,7 +164,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): ): condition = ATTR_CONDITION_CLEAR_NIGHT - data.append( + new_forecast = Forecast( { ATTR_FORECAST_TIME: forecast["valid_time"].isoformat(), ATTR_FORECAST_NATIVE_TEMP: forecast["temperature_max"], @@ -178,13 +182,23 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): ATTR_FORECAST_CLOUD_COVERAGE: forecast["total_cloud"], } ) + if forecast_type == "twice_daily": + new_forecast[ATTR_FORECAST_IS_DAYTIME] = False + if forecast["valid_time"].hour == 12: + new_forecast[ATTR_FORECAST_IS_DAYTIME] = True + + data.append(new_forecast) return data def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self.coordinator.data.daily) + return self._get_forecast_data(self.coordinator.data.daily, "daily") def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self.coordinator.data.hourly) + return self._get_forecast_data(self.coordinator.data.hourly, "hourly") + + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Service to retrieve the twice daily forecast.""" + return self._get_forecast_data(self.coordinator.data.twice_daily, "twice_daily") diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 9a37cc554c7..9340573f6ce 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.6"], + "requirements": ["pysmlight==0.2.7"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 6c7c5374f7d..78f7899a571 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -83,8 +83,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not gateway: raise ConfigEntryNotReady(f"Cannot find device {device}") - signal_coordinator = SignalCoordinator(hass, gateway) - network_coordinator = NetworkCoordinator(hass, gateway) + signal_coordinator = SignalCoordinator(hass, entry, gateway) + network_coordinator = NetworkCoordinator(hass, entry, gateway) # Fetch initial data so we have data when entities subscribe await signal_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/sms/coordinator.py b/homeassistant/components/sms/coordinator.py index 7bc691afedf..858fc303805 100644 --- a/homeassistant/components/sms/coordinator.py +++ b/homeassistant/components/sms/coordinator.py @@ -16,13 +16,14 @@ _LOGGER = logging.getLogger(__name__) class SignalCoordinator(DataUpdateCoordinator): """Signal strength coordinator.""" - def __init__(self, hass, gateway): + def __init__(self, hass, entry, gateway): """Initialize signal strength coordinator.""" super().__init__( hass, _LOGGER, name="Device signal state", update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, ) self._gateway = gateway @@ -38,13 +39,14 @@ class SignalCoordinator(DataUpdateCoordinator): class NetworkCoordinator(DataUpdateCoordinator): """Network info coordinator.""" - def __init__(self, hass, gateway): + def __init__(self, hass, entry, gateway): """Initialize network info coordinator.""" super().__init__( hass, _LOGGER, name="Device network state", update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, ) self._gateway = gateway diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 4c2f0cb81b7..963f12887fc 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -39,6 +39,8 @@ class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): self._server.set_on_connect_callback(self._on_connect) self._server.set_on_disconnect_callback(self._on_disconnect) + self._host_id = f"{host}:{port}" + def _on_update(self) -> None: """Snapserver on_update callback.""" # Assume availability if an update is received. @@ -77,3 +79,8 @@ class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): def server(self) -> Snapserver: """Get the Snapserver object.""" return self._server + + @property + def host_id(self) -> str: + """Get the host ID.""" + return self._host_id diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 5f011ca41ee..ccb9d4c4c46 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -12,18 +12,20 @@ import voluptuous as vol from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, entity_platform, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -50,6 +52,12 @@ STREAM_STATUS = { "unknown": None, } +_SUPPORTED_FEATURES = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.SELECT_SOURCE +) + _LOGGER = logging.getLogger(__name__) @@ -80,118 +88,106 @@ async def async_setup_entry( # Fetch coordinator from global data coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - # Create an ID for the Snapserver - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - host_id = f"{host}:{port}" - register_services() _known_group_ids: set[str] = set() _known_client_ids: set[str] = set() @callback - def _check_entities() -> None: - nonlocal _known_group_ids, _known_client_ids + def _update_entities( + entity_class: type[SnapcastClientDevice | SnapcastGroupDevice], + known_ids: set[str], + get_device: Callable[[str], Snapclient | Snapgroup], + get_devices: Callable[[], list[Snapclient] | list[Snapgroup]], + ) -> None: + # Get IDs of current devices on server + snapcast_ids = {d.identifier for d in get_devices()} - def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]: - ids_to_add = ids - known_ids - ids_to_remove = known_ids - ids + # Update known IDs + ids_to_add = snapcast_ids - known_ids + ids_to_remove = known_ids - snapcast_ids - # Update known IDs - known_ids.difference_update(ids_to_remove) - known_ids.update(ids_to_add) - - return ids_to_add, ids_to_remove - - group_ids = {g.identifier for g in coordinator.server.groups} - groups_to_add, groups_to_remove = _update_known_ids(_known_group_ids, group_ids) - - client_ids = {c.identifier for c in coordinator.server.clients} - clients_to_add, clients_to_remove = _update_known_ids( - _known_client_ids, client_ids - ) + known_ids.difference_update(ids_to_remove) + known_ids.update(ids_to_add) # Exit early if no changes - if not (groups_to_add | groups_to_remove | clients_to_add | clients_to_remove): + if not (ids_to_add | ids_to_remove): return _LOGGER.debug( - "New clients: %s", - str([coordinator.server.client(c).friendly_name for c in clients_to_add]), + "New %s: %s", + entity_class, + str([get_device(d).friendly_name for d in ids_to_add]), ) _LOGGER.debug( - "New groups: %s", - str([coordinator.server.group(g).friendly_name for g in groups_to_add]), - ) - _LOGGER.debug( - "Remove client IDs: %s", - str([list(clients_to_remove)]), - ) - _LOGGER.debug( - "Remove group IDs: %s", - str(list(groups_to_remove)), + "Remove %s IDs: %s", + entity_class, + str([list(ids_to_remove)]), ) # Add new entities async_add_entities( [ - SnapcastGroupDevice( - coordinator, coordinator.server.group(group_id), host_id - ) - for group_id in groups_to_add - ] - + [ - SnapcastClientDevice( - coordinator, coordinator.server.client(client_id), host_id - ) - for client_id in clients_to_add + entity_class(coordinator, get_device(snapcast_id)) + for snapcast_id in ids_to_add ] ) # Remove stale entities entity_registry = er.async_get(hass) - for group_id in groups_to_remove: + for snapcast_id in ids_to_remove: if entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, - SnapcastGroupDevice.get_unique_id(host_id, group_id), + entity_class.get_unique_id(coordinator.host_id, snapcast_id), ): entity_registry.async_remove(entity_id) - for client_id in clients_to_remove: - if entity_id := entity_registry.async_get_entity_id( - MEDIA_PLAYER_DOMAIN, - DOMAIN, - SnapcastClientDevice.get_unique_id(host_id, client_id), - ): - entity_registry.async_remove(entity_id) + def _update_clients() -> None: + _update_entities( + SnapcastClientDevice, + _known_client_ids, + coordinator.server.client, + lambda: coordinator.server.clients, + ) - coordinator.async_add_listener(_check_entities) - _check_entities() + # Create client entities and add listener to update clients on server update + _update_clients() + coordinator.async_add_listener(_update_clients) + + def _update_groups() -> None: + _update_entities( + SnapcastGroupDevice, + _known_group_ids, + coordinator.server.group, + lambda: coordinator.server.groups, + ) + + # Create group entities and add listener to update groups on server update + _update_groups() + coordinator.async_add_listener(_update_groups) class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Base class representing a Snapcast device.""" _attr_should_poll = False - _attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + _attr_supported_features = _SUPPORTED_FEATURES + _attr_media_content_type = MediaType.MUSIC + _attr_device_class = MediaPlayerDeviceClass.SPEAKER def __init__( self, coordinator: SnapcastUpdateCoordinator, device: Snapgroup | Snapclient, - host_id: str, ) -> None: """Initialize the base device.""" super().__init__(coordinator) self._device = device - self._attr_unique_id = self.get_unique_id(host_id, device.identifier) + self._attr_unique_id = self.get_unique_id( + coordinator.host_id, device.identifier + ) @classmethod def get_unique_id(cls, host, id) -> str: @@ -275,6 +271,89 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Handle the unjoin service.""" raise NotImplementedError + def _async_create_grouping_deprecation_issue(self) -> None: + """Create an issue for deprecated grouping actions.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "deprecated_grouping_actions", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_grouping_actions", + ) + + @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 + + # Fallback to an empty dict + return {} + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + return self.metadata.get("title") + + @property + def media_image_url(self) -> str | None: + """Image url of current playing media.""" + return self.metadata.get("artUrl") + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if (value := self.metadata.get("artist")) is not None: + return ", ".join(value) + + return None + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + return self.metadata.get("album") + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + if (value := self.metadata.get("albumArtist")) is not None: + return ", ".join(value) + + return None + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + if (value := self.metadata.get("trackNumber")) is not None: + return int(value) + + return None + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if (value := self.metadata.get("duration")) is not None: + return int(value) + + return 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) + + return None + class SnapcastGroupDevice(SnapcastBaseDevice): """Representation of a Snapcast group device.""" @@ -315,11 +394,62 @@ class SnapcastGroupDevice(SnapcastBaseDevice): """Handle the unjoin service.""" raise ServiceValidationError("Entity is not a client. Can only unjoin clients.") + def _async_create_group_deprecation_issue(self) -> None: + """Create an issue for deprecated group entities.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "deprecated_group_entities", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_group_entities", + ) + + async def async_select_source(self, source: str) -> None: + """Set input source.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_select_source(source) + + async def async_mute_volume(self, mute: bool) -> None: + """Send the mute command.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_mute_volume(mute) + + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_set_volume_level(volume) + + def snapshot(self) -> None: + """Snapshot the group state.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + super().snapshot() + + async def async_restore(self) -> None: + """Restore the group state.""" + # Groups are deprecated, create an issue when used + self._async_create_group_deprecation_issue() + + await super().async_restore() + class SnapcastClientDevice(SnapcastBaseDevice): """Representation of a Snapcast client device.""" _device: Snapclient + _attr_supported_features = ( + _SUPPORTED_FEATURES | MediaPlayerEntityFeature.GROUPING + ) # Clients support grouping @classmethod def get_unique_id(cls, host, id) -> str: @@ -343,7 +473,7 @@ class SnapcastClientDevice(SnapcastBaseDevice): if self.is_volume_muted or self._current_group.muted: return MediaPlayerState.IDLE return STREAM_STATUS.get(self._current_group.stream_status) - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF @property def extra_state_attributes(self) -> Mapping[str, Any]: @@ -365,6 +495,9 @@ class SnapcastClientDevice(SnapcastBaseDevice): async def async_join(self, master) -> None: """Join the group of the master player.""" + # Action is deprecated, create an issue + self._async_create_grouping_deprecation_issue() + entity_registry = er.async_get(self.hass) master_entity = entity_registry.async_get(master) if master_entity is None: @@ -389,5 +522,53 @@ class SnapcastClientDevice(SnapcastBaseDevice): async def async_unjoin(self) -> None: """Unjoin the group the player is currently in.""" + # Action is deprecated, create an issue + self._async_create_grouping_deprecation_issue() + + await self._current_group.remove_client(self._device.identifier) + self.async_write_ha_state() + + @property + def group_members(self) -> list[str] | None: + """List of player entities which are currently grouped together for synchronous playback.""" + entity_registry = er.async_get(self.hass) + return [ + entity_id + for client_id in self._current_group.clients + if ( + entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, + DOMAIN, + self.get_unique_id(self.coordinator.host_id, client_id), + ) + ) + ] + + async def async_join_players(self, group_members: list[str]) -> None: + """Add `group_members` to this client's current group.""" + # Get the client entity for each group member excluding self + entity_registry = er.async_get(self.hass) + clients = [ + entity + for entity_id in group_members + if (entity := entity_registry.async_get(entity_id)) + and entity.unique_id != self.unique_id + ] + + for client in clients: + # Valid 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." + ) + + # Extract client ID and join it to the current group + identifier = client.unique_id.split("_")[-1] + await self._current_group.add_client(identifier) + + self.async_write_ha_state() + + async def async_unjoin_player(self) -> None: + """Remove this client from it's current group.""" await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state() diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 685b4a0dd11..9336b1fac86 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -58,5 +58,15 @@ } } } + }, + "issues": { + "deprecated_grouping_actions": { + "title": "Snapcast Actions Deprecated", + "description": "Actions 'snapcast.join' and 'snapcast.unjoin' are deprecated and will be removed in 2026.2. Use the 'media_player.join' and 'media_player.unjoin' actions instead." + }, + "deprecated_group_entities": { + "title": "Snapcast Groups Entities Deprecated", + "description": "Snapcast group entities are deprecated and will be removed in 2026.2. Please use the 'media_player.join' and 'media_player.unjoin' actions instead." + } } } diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index f69c844f191..eb963ce6a42 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -7,13 +7,13 @@ import logging from typing import TYPE_CHECKING from pysnmp.error import PySnmpError -from pysnmp.hlapi.asyncio import ( +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, Udp6TransportTarget, UdpTransportTarget, UsmUserData, - bulkWalkCmd, - isEndOfMib, + bulk_walk_cmd, + is_end_of_mib, ) import voluptuous as vol @@ -59,7 +59,7 @@ async def async_get_scanner( hass: HomeAssistant, config: ConfigType ) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" - scanner = SnmpScanner(config[DEVICE_TRACKER_DOMAIN]) + scanner = await SnmpScanner.create(config[DEVICE_TRACKER_DOMAIN]) await scanner.async_init(hass) return scanner if scanner.success_init else None @@ -69,8 +69,8 @@ class SnmpScanner(DeviceScanner): """Queries any SNMP capable Access Point for connected devices.""" def __init__(self, config): - """Initialize the scanner and test the target device.""" - host = config[CONF_HOST] + """Initialize the scanner after testing the target device.""" + community = config[CONF_COMMUNITY] baseoid = config[CONF_BASEOID] authkey = config.get(CONF_AUTH_KEY) @@ -78,19 +78,6 @@ class SnmpScanner(DeviceScanner): privkey = config.get(CONF_PRIV_KEY) privproto = DEFAULT_PRIV_PROTOCOL - try: - # Try IPv4 first. - target = UdpTransportTarget((host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT) - except PySnmpError: - # Then try IPv6. - try: - target = Udp6TransportTarget( - (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT - ) - except PySnmpError as err: - _LOGGER.error("Invalid SNMP host: %s", err) - return - if authkey is not None or privkey is not None: if not authkey: authproto = "none" @@ -109,16 +96,43 @@ class SnmpScanner(DeviceScanner): community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION] ) - self._target = target + self._target: UdpTransportTarget | Udp6TransportTarget self.request_args: RequestArgsType | None = None self.baseoid = baseoid self.last_results = [] self.success_init = False + @classmethod + async def create(cls, config): + """Asynchronously test the target device before fully initializing the scanner.""" + host = config[CONF_HOST] + + try: + # Try IPv4 first. + target = await UdpTransportTarget.create( + (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT + ) + except PySnmpError: + # Then try IPv6. + try: + target = Udp6TransportTarget( + (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT + ) + except PySnmpError as err: + _LOGGER.error("Invalid SNMP host: %s", err) + return None + instance = cls(config) + instance._target = target + + return instance + async def async_init(self, hass: HomeAssistant) -> None: """Make a one-off read to check if the target device is reachable and readable.""" self.request_args = await async_create_request_cmd_args( - hass, self._auth_data, self._target, self.baseoid + hass, + self._auth_data, + self._target, + self.baseoid, ) data = await self.async_get_snmp_data() self.success_init = data is not None @@ -154,7 +168,7 @@ class SnmpScanner(DeviceScanner): assert self.request_args is not None engine, auth_data, target, context_data, object_type = self.request_args - walker = bulkWalkCmd( + walker = bulk_walk_cmd( engine, auth_data, target, @@ -177,7 +191,7 @@ class SnmpScanner(DeviceScanner): return None for _oid, value in res: - if not isEndOfMib(res): + if not is_end_of_mib(res): try: mac = binascii.hexlify(value.asOctets()).decode("utf-8") except AttributeError: diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index a2a4405a1b5..ebe1bcc0262 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], "quality_scale": "legacy", - "requirements": ["pysnmp==6.2.6"] + "requirements": ["pysnmp==7.1.21"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index bd50e2050e0..46e0dc83050 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -8,13 +8,13 @@ from struct import unpack from pyasn1.codec.ber import decoder from pysnmp.error import PySnmpError -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( +import pysnmp.hlapi.v3arch.asyncio as hlapi +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, Udp6TransportTarget, UdpTransportTarget, UsmUserData, - getCmd, + get_cmd, ) from pysnmp.proto.rfc1902 import Opaque from pysnmp.proto.rfc1905 import NoSuchObject @@ -134,7 +134,7 @@ async def async_setup_platform( try: # Try IPv4 first. - target = UdpTransportTarget((host, port), timeout=DEFAULT_TIMEOUT) + target = await UdpTransportTarget.create((host, port), timeout=DEFAULT_TIMEOUT) except PySnmpError: # Then try IPv6. try: @@ -159,7 +159,7 @@ async def async_setup_platform( auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) request_args = await async_create_request_cmd_args(hass, auth_data, target, baseoid) - get_result = await getCmd(*request_args) + get_result = await get_cmd(*request_args) errindication, _, _, _ = get_result if errindication and not accept_errors: @@ -217,7 +217,7 @@ class SnmpSensor(ManualTriggerSensorEntity): self.entity_id, variables, STATE_UNKNOWN ) - self._attr_native_value = value + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) @@ -235,7 +235,7 @@ class SnmpData: async def async_update(self): """Get the latest data from the remote SNMP capable host.""" - get_result = await getCmd(*self._request_args) + get_result = await get_cmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index fd405567d60..26fb7d5e99d 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -5,15 +5,15 @@ from __future__ import annotations import logging from typing import Any -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( +import pysnmp.hlapi.v3arch.asyncio as hlapi +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, ObjectIdentity, ObjectType, UdpTransportTarget, UsmUserData, - getCmd, - setCmd, + get_cmd, + set_cmd, ) from pysnmp.proto.rfc1902 import ( Counter32, @@ -169,7 +169,7 @@ async def async_setup_platform( else: auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) - transport = UdpTransportTarget((host, port)) + transport = await UdpTransportTarget.create((host, port)) request_args = await async_create_request_cmd_args( hass, auth_data, transport, baseoid ) @@ -228,10 +228,17 @@ class SnmpSwitch(SwitchEntity): self._state: bool | None = None self._payload_on = payload_on self._payload_off = payload_off - self._target = UdpTransportTarget((host, port)) + self._host = host + self._port = port self._request_args = request_args self._command_args = command_args + async def async_added_to_hass(self) -> None: + """Run when this Entity has been added to HA.""" + # The transport creation is done once this entity is registered with HA + # (rather than in the __init__) + self._target = await UdpTransportTarget.create((self._host, self._port)) # pylint: disable=attribute-defined-outside-init + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" # If vartype set, use it - https://www.pysnmp.com/pysnmp/docs/api-reference.html#pysnmp.smi.rfc1902.ObjectType @@ -255,7 +262,7 @@ class SnmpSwitch(SwitchEntity): async def async_update(self) -> None: """Update the state.""" - get_result = await getCmd(*self._request_args) + get_result = await get_cmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication: @@ -291,6 +298,6 @@ class SnmpSwitch(SwitchEntity): async def _set(self, value: Any) -> None: """Set the state of the switch.""" - await setCmd( + await set_cmd( *self._command_args, ObjectType(ObjectIdentity(self._commandoid), value) ) diff --git a/homeassistant/components/snmp/util.py b/homeassistant/components/snmp/util.py index dd3e9a6b6d2..df0171b6610 100644 --- a/homeassistant/components/snmp/util.py +++ b/homeassistant/components/snmp/util.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from pysnmp.hlapi.asyncio import ( +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, ContextData, ObjectIdentity, @@ -14,8 +14,8 @@ from pysnmp.hlapi.asyncio import ( UdpTransportTarget, UsmUserData, ) -from pysnmp.hlapi.asyncio.cmdgen import lcd, vbProcessor -from pysnmp.smi.builder import MibBuilder +from pysnmp.hlapi.v3arch.asyncio.cmdgen import LCD +from pysnmp.smi import view from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -80,7 +80,7 @@ async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: @callback def _async_shutdown_listener(ev: Event) -> None: _LOGGER.debug("Unconfiguring SNMP engine") - lcd.unconfigure(engine, None) + LCD.unconfigure(engine, None) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_listener) return engine @@ -89,10 +89,10 @@ async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: def _get_snmp_engine() -> SnmpEngine: """Return a cached instance of SnmpEngine.""" engine = SnmpEngine() - mib_controller = vbProcessor.getMibViewController(engine) - # Actually load the MIBs from disk so we do - # not do it in the event loop - builder: MibBuilder = mib_controller.mibBuilder - if "PYSNMP-MIB" not in builder.mibSymbols: - builder.loadModules() + # Actually load the MIBs from disk so we do not do it in the event loop + mib_view_controller = view.MibViewController( + engine.message_dispatcher.mib_instrum_controller.get_mib_builder() + ) + engine.cache["mibViewController"] = mib_view_controller + mib_view_controller.mibBuilder.load_modules() return engine diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index 54834bf58ce..20d94be7c03 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool coordinators: dict[str, SnooCoordinator] = {} tasks = [] for device in devices: - coordinators[device.serialNumber] = SnooCoordinator(hass, device, snoo) + coordinators[device.serialNumber] = SnooCoordinator(hass, entry, device, snoo) tasks.append(coordinators[device.serialNumber].setup()) await asyncio.gather(*tasks) entry.runtime_data = coordinators diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py index 3c91db5b86d..c4eaddcc1fe 100644 --- a/homeassistant/components/snoo/binary_sensor.py +++ b/homeassistant/components/snoo/binary_sensor.py @@ -38,7 +38,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [ SnooBinarySensorEntityDescription( key="right_clip", translation_key="right_clip", - value_fn=lambda data: data.left_safety_clip, + value_fn=lambda data: data.right_safety_clip, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py index bc06d20955c..43e717c2bc7 100644 --- a/homeassistant/components/snoo/coordinator.py +++ b/homeassistant/components/snoo/coordinator.py @@ -19,11 +19,18 @@ class SnooCoordinator(DataUpdateCoordinator[SnooData]): config_entry: SnooConfigEntry - def __init__(self, hass: HomeAssistant, device: SnooDevice, snoo: Snoo) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: SnooConfigEntry, + device: SnooDevice, + snoo: Snoo, + ) -> None: """Set up Snoo Coordinator.""" super().__init__( hass, name=device.name, + config_entry=entry, logger=_LOGGER, ) self.device_unique_id = device.serialNumber @@ -33,7 +40,7 @@ class SnooCoordinator(DataUpdateCoordinator[SnooData]): async def setup(self) -> None: """Perform setup needed on every coordintaor creation.""" - await self.snoo.subscribe(self.device, self.async_set_updated_data) + self.snoo.start_subscribe(self.device, self.async_set_updated_data) # After we subscribe - get the status so that we have something to start with. # We only need to do this once. The device will auto update otherwise. await self.snoo.get_status(self.device) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 2afec990e4b..5a162a9e9d3 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.6"] + "requirements": ["python-snoo==0.8.3"] } diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 486b30edfd3..4a4101a2dd3 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", - "requirements": ["solarlog_cli==0.4.0"] + "requirements": ["solarlog_cli==0.5.0"] } diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index c4bb119c006..a3a450fe49e 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from solarlog_cli.solarlog_models import InverterData, SolarlogData +from solarlog_cli.solarlog_models import BatteryData, InverterData, SolarlogData from homeassistant.components.sensor import ( SensorDeviceClass, @@ -35,6 +35,13 @@ class SolarLogCoordinatorSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[SolarlogData], StateType | datetime | None] +@dataclass(frozen=True, kw_only=True) +class SolarLogBatterySensorEntityDescription(SensorEntityDescription): + """Describes Solarlog battery sensor entity.""" + + value_fn: Callable[[BatteryData], float | int | None] + + @dataclass(frozen=True, kw_only=True) class SolarLogInverterSensorEntityDescription(SensorEntityDescription): """Describes Solarlog inverter sensor entity.""" @@ -247,6 +254,33 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = ), ) +BATTERY_SENSOR_TYPES: tuple[SolarLogBatterySensorEntityDescription, ...] = ( + SolarLogBatterySensorEntityDescription( + key="charging_power", + translation_key="charging_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery_data: battery_data.charge_power, + ), + SolarLogBatterySensorEntityDescription( + key="discharging_power", + translation_key="discharging_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery_data: battery_data.discharge_power, + ), + SolarLogBatterySensorEntityDescription( + key="charge_level", + translation_key="charge_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery_data: battery_data.level, + ), +) + INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( SolarLogInverterSensorEntityDescription( key="current_power", @@ -286,6 +320,13 @@ async def async_setup_entry( for sensor in SOLARLOG_SENSOR_TYPES ] + # add battery sensors only if respective data is available (otherwise no battery attached to solarlog) + if coordinator.data.battery_data is not None: + entities.extend( + SolarLogBatterySensor(coordinator, sensor) + for sensor in BATTERY_SENSOR_TYPES + ) + device_data = coordinator.data.inverter_data if device_data: @@ -318,6 +359,19 @@ class SolarLogCoordinatorSensor(SolarLogCoordinatorEntity, SensorEntity): return self.entity_description.value_fn(self.coordinator.data) +class SolarLogBatterySensor(SolarLogCoordinatorEntity, SensorEntity): + """Represents a SolarLog battery sensor.""" + + entity_description: SolarLogBatterySensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state for this sensor.""" + if (battery_data := self.coordinator.data.battery_data) is None: + return None + return self.entity_description.value_fn(battery_data) + + class SolarLogInverterSensor(SolarLogInverterEntity, SensorEntity): """Represents a SolarLog inverter sensor.""" diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index bf87b0b0938..bba1380fb9f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -58,6 +58,15 @@ }, "entity": { "sensor": { + "charge_level": { + "name": "Charge level" + }, + "charging_power": { + "name": "Charging power" + }, + "discharging_power": { + "name": "Discharging power" + }, "last_update": { "name": "Last update" }, diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 89796f5ce46..fdbaaf9f427 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -11,8 +11,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DOMAIN, MYLINK_STATUS, PLATFORMS -UNDO_UPDATE_LISTENER = "undo_update_listener" - _LOGGER = logging.getLogger(__name__) @@ -44,12 +42,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if "result" not in mylink_status: raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result") - undo_listener = entry.add_update_listener(_async_update_listener) - hass.data[DOMAIN][entry.entry_id] = { DATA_SOMFY_MYLINK: somfy_mylink, MYLINK_STATUS: mylink_status, - UNDO_UPDATE_LISTENER: undo_listener, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -57,18 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - 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) - - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index a806d581aec..91cfae87347 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -125,7 +125,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for somfy_mylink.""" def __init__(self, config_entry: ConfigEntry) -> None: diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 90489c0ba34..ec501fac302 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -29,13 +29,13 @@ }, "step": { "init": { - "title": "Configure MyLink Options", + "title": "Configure MyLink options", "data": { "target_id": "Configure options for a cover." } }, "target_config": { - "title": "Configure MyLink Cover", + "title": "Configure MyLink cover", "description": "Configure options for `{target_name}`", "data": { "reverse": "Cover is reversed" diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 960227ff0da..1c786356486 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -65,7 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass), ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { "upcoming": CalendarDataUpdateCoordinator( hass, entry, host_configuration, sonarr @@ -126,8 +125,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index e1cedba10e7..278d3fbd7bb 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -152,7 +152,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): return data_schema -class SonarrOptionsFlowHandler(OptionsFlow): +class SonarrOptionsFlowHandler(OptionsFlowWithReload): """Handle Sonarr client options.""" async def async_step_init( diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 76e0a915060..ac2e3f50f13 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -15,6 +15,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -154,6 +155,7 @@ SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor" +SONOS_CREATE_SELECTS = "sonos_create_selects" SONOS_CREATE_SWITCHES = "sonos_create_switches" SONOS_CREATE_LEVELS = "sonos_create_levels" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" @@ -186,6 +188,12 @@ MODELS_TV_ONLY = ( "ULTRA", ) MODELS_LINEIN_AND_TV = ("AMP",) +MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA" + +ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled" +SPEECH_DIALOG_LEVEL = "speech_dialog_level" +ATTR_DIALOG_LEVEL = "dialog_level" +ATTR_DIALOG_LEVEL_ENUM = "dialog_level_enum" AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 35d81edbea0..fafa142273a 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -6,6 +6,7 @@ import time from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN @@ -132,11 +133,23 @@ async def async_generate_speaker_info( value = getattr(speaker, attrib) payload[attrib] = get_contents(value) + entity_registry = er.async_get(hass) payload["enabled_entities"] = sorted( - entity_id - for entity_id, s in config_entry.runtime_data.entity_id_mappings.items() - if s is speaker + registry_entry.entity_id + for registry_entry in entity_registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ) + if ( + ( + entity_speaker + := config_entry.runtime_data.unique_id_speaker_mappings.get( + registry_entry.unique_id + ) + ) + and speaker.uid == entity_speaker.uid + ) ) + payload["media"] = await async_generate_media_info(hass, speaker) payload["activity_stats"] = speaker.activity_stats.report() payload["event_stats"] = speaker.event_stats.report() diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 58108f9974c..5f7a2fb2d70 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -34,7 +34,10 @@ class SonosEntity(Entity): async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - self.config_entry.runtime_data.entity_id_mappings[self.entity_id] = self.speaker + assert self.unique_id + self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] = ( + self.speaker + ) self.async_on_remove( async_dispatcher_connect( self.hass, @@ -52,7 +55,8 @@ class SonosEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - del self.config_entry.runtime_data.entity_id_mappings[self.entity_id] + assert self.unique_id + del self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index f8b3dbbe492..c1e1b4f80df 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -72,10 +72,10 @@ class SonosFavorites(SonosHouseholdCoordinator): """Process the event payload in an async lock and update entities.""" event_id = event.variables["favorites_update_id"] container_ids = event.variables["container_update_i_ds"] - if not (match := re.search(r"FV:2,(\d+)", container_ids)): + if not container_ids or not (match := re.search(r"FV:2,(\d+)", container_ids)): return - container_id = int(match.groups()[0]) + container_id = int(match.group(1)) event_id = int(event_id.split(",")[-1]) async with self.cache_update_lock: diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 3350df430f8..1fb3bb3d5e7 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -149,7 +149,8 @@ class SonosData: discovery_known: set[str] = field(default_factory=set) boot_counts: dict[str, int] = field(default_factory=dict) mdns_names: dict[str, str] = field(default_factory=dict) - entity_id_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) + # Maps the entity unique id to the associated SonosSpeaker instance. + unique_id_speaker_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) unjoin_data: dict[str, UnjoinData] = field(default_factory=dict) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 5bbfc33ae5b..79a50ef4732 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco", "sonos_websocket"], - "requirements": ["soco==0.30.9", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.11", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 96e4d34ddc4..0b30c820da3 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -43,7 +43,12 @@ from homeassistant.components.plex.services import process_plex_payload from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_platform, service +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, + service, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -788,8 +793,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if one_alarm.alarm_id == str(alarm_id): alarm = one_alarm if alarm is None: - _LOGGER.warning("Did not find alarm with id %s", alarm_id) - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_alarm_id", + translation_placeholders={ + "alarm_id": str(alarm_id), + }, + ) if time is not None: alarm.start_time = time if volume is not None: @@ -880,13 +890,28 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" speakers = [] + + entity_registry = er.async_get(self.hass) for entity_id in group_members: - if speaker := self.config_entry.runtime_data.entity_id_mappings.get( - entity_id + if not (entity_reg_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if not ( + speaker + := self.config_entry.runtime_data.unique_id_speaker_mappings.get( + entity_reg_entry.unique_id + ) ): - speakers.append(speaker) - else: - raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="speaker_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + speakers.append(speaker) await SonosSpeaker.join_multi( self.hass, self.config_entry, self.speaker, speakers diff --git a/homeassistant/components/sonos/quality_scale.yaml b/homeassistant/components/sonos/quality_scale.yaml new file mode 100644 index 00000000000..5899503ae8d --- /dev/null +++ b/homeassistant/components/sonos/quality_scale.yaml @@ -0,0 +1,76 @@ +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: + status: exempt + comment: | + Setup is done through discovery and does not require a test before setup. + unique-config-entry: + status: exempt + comment: | + Integration only supports and uses a single config entry. Exempting because hassfest check is incomplete. + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + No authentication + test-coverage: + status: todo + comment: | + test_play_media_library if statements in the tests + PR #147064 + test_sensor is testing both binary sensor and sensor + tests using internals + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: | + No configurable options + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py new file mode 100644 index 00000000000..052a1d87967 --- /dev/null +++ b/homeassistant/components/sonos/select.py @@ -0,0 +1,129 @@ +"""Select entities for Sonos.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + ATTR_DIALOG_LEVEL, + ATTR_DIALOG_LEVEL_ENUM, + MODEL_SONOS_ARC_ULTRA, + SONOS_CREATE_SELECTS, + SPEECH_DIALOG_LEVEL, +) +from .entity import SonosEntity +from .helpers import SonosConfigEntry, soco_error +from .speaker import SonosSpeaker + + +@dataclass(frozen=True, kw_only=True) +class SonosSelectEntityDescription(SelectEntityDescription): + """Describes AirGradient select entity.""" + + soco_attribute: str + speaker_attribute: str + speaker_model: str + + +SELECT_TYPES: list[SonosSelectEntityDescription] = [ + SonosSelectEntityDescription( + key=SPEECH_DIALOG_LEVEL, + translation_key=SPEECH_DIALOG_LEVEL, + soco_attribute=ATTR_DIALOG_LEVEL, + speaker_attribute=ATTR_DIALOG_LEVEL_ENUM, + speaker_model=MODEL_SONOS_ARC_ULTRA, + options=["off", "low", "medium", "high", "max"], + ), +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sonos select platform from a config entry.""" + + def available_soco_attributes( + speaker: SonosSpeaker, + ) -> list[SonosSelectEntityDescription]: + features: list[SonosSelectEntityDescription] = [] + for select_data in SELECT_TYPES: + if select_data.speaker_model == speaker.model_name.upper(): + if ( + state := getattr(speaker.soco, select_data.soco_attribute, None) + ) is not None: + setattr(speaker, select_data.speaker_attribute, state) + features.append(select_data) + return features + + async def _async_create_entities(speaker: SonosSpeaker) -> None: + available_features = await hass.async_add_executor_job( + available_soco_attributes, speaker + ) + async_add_entities( + SonosSelectEntity(speaker, config_entry, select_data) + for select_data in available_features + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_SELECTS, _async_create_entities) + ) + + +class SonosSelectEntity(SonosEntity, SelectEntity): + """Representation of a Sonos select entity.""" + + def __init__( + self, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, + select_data: SonosSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-{select_data.key}" + self._attr_translation_key = select_data.translation_key + assert select_data.options is not None + self._attr_options = select_data.options + self.speaker_attribute = select_data.speaker_attribute + self.soco_attribute = select_data.soco_attribute + + async def _async_fallback_poll(self) -> None: + """Poll the value if subscriptions are not working.""" + await self.hass.async_add_executor_job(self.poll_state) + self.async_write_ha_state() + + @soco_error() + def poll_state(self) -> None: + """Poll the device for the current state.""" + state = getattr(self.soco, self.soco_attribute) + setattr(self.speaker, self.speaker_attribute, state) + + @property + def current_option(self) -> str | None: + """Return the current option for the entity.""" + option = getattr(self.speaker, self.speaker_attribute, None) + if not isinstance(option, int) or not (0 <= option < len(self._attr_options)): + _LOGGER.error( + "Invalid option %s for %s on %s", + option, + self.soco_attribute, + self.speaker.zone_name, + ) + return None + return self._attr_options[option] + + @soco_error() + def select_option(self, option: str) -> None: + """Set a new value.""" + dialog_level = self._attr_options.index(option) + setattr(self.soco, self.soco_attribute, dialog_level) diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 6b507ec910a..fcb04a10e98 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -24,6 +24,20 @@ from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) +SONOS_POWER_SOURCE_BATTERY = "BATTERY" +SONOS_POWER_SOURCE_CHARGING_RING = "SONOS_CHARGING_RING" +SONOS_POWER_SOURCE_USB = "USB_POWER" + +HA_POWER_SOURCE_BATTERY = "battery" +HA_POWER_SOURCE_CHARGING_BASE = "charging_base" +HA_POWER_SOURCE_USB = "usb" + +power_source_map = { + SONOS_POWER_SOURCE_BATTERY: HA_POWER_SOURCE_BATTERY, + SONOS_POWER_SOURCE_CHARGING_RING: HA_POWER_SOURCE_CHARGING_BASE, + SONOS_POWER_SOURCE_USB: HA_POWER_SOURCE_USB, +} + async def async_setup_entry( hass: HomeAssistant, @@ -42,9 +56,15 @@ async def async_setup_entry( @callback def _async_create_battery_sensor(speaker: SonosSpeaker) -> None: - _LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name) - entity = SonosBatteryEntity(speaker, config_entry) - async_add_entities([entity]) + _LOGGER.debug( + "Creating battery level and power source sensor on %s", speaker.zone_name + ) + async_add_entities( + [ + SonosBatteryEntity(speaker, config_entry), + SonosPowerSourceEntity(speaker, config_entry), + ] + ) @callback def _async_create_favorites_sensor(favorites: SonosFavorites) -> None: @@ -101,6 +121,48 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): return self.speaker.available and self.speaker.power_source is not None +class SonosPowerSourceEntity(SonosEntity, SensorEntity): + """Representation of a Sonos Power Source entity.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_options = [ + HA_POWER_SOURCE_BATTERY, + HA_POWER_SOURCE_CHARGING_BASE, + HA_POWER_SOURCE_USB, + ] + _attr_translation_key = "power_source" + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the power source sensor.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-power_source" + + async def _async_fallback_poll(self) -> None: + """Poll the device for the current state.""" + await self.speaker.async_poll_battery() + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + if not (power_source := self.speaker.power_source): + return None + if not (value := power_source_map.get(power_source)): + _LOGGER.warning( + "Unknown power source '%s' for speaker %s", + power_source, + self.speaker.zone_name, + ) + return None + return value + + @property + def available(self) -> bool: + """Return whether this entity is available.""" + return self.speaker.available and self.speaker.power_source is not None + + class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity): """Representation of a Sonos audio import format sensor entity.""" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index aee0a40c184..427f02f0479 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -35,6 +35,8 @@ from homeassistant.util import dt as dt_util from .alarms import SonosAlarms from .const import ( + ATTR_DIALOG_LEVEL, + ATTR_SPEECH_ENHANCEMENT_ENABLED, AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, DOMAIN, @@ -46,6 +48,7 @@ from .const import ( SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, + SONOS_CREATE_SELECTS, SONOS_CREATE_SWITCHES, SONOS_FALLBACK_POLL, SONOS_REBOOTED, @@ -157,6 +160,8 @@ class SonosSpeaker: # Home theater self.audio_delay: int | None = None self.dialog_level: bool | None = None + self.dialog_level_enum: int | None = None + self.speech_enhance_enabled: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None self.sub_crossover: int | None = None @@ -251,6 +256,7 @@ class SonosSpeaker: ]: dispatches.append((SONOS_CREATE_ALARM, self, new_alarms)) + dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -548,6 +554,11 @@ class SonosSpeaker: @callback def async_update_volume(self, event: SonosEvent) -> None: """Update information about currently volume settings.""" + _LOGGER.debug( + "Updating volume for %s with event variables: %s", + self.zone_name, + event.variables, + ) self.event_stats.process(event) variables = event.variables @@ -565,6 +576,7 @@ class SonosSpeaker: for bool_var in ( "dialog_level", + ATTR_SPEECH_ENHANCEMENT_ENABLED, "night_mode", "sub_enabled", "surround_enabled", @@ -585,6 +597,10 @@ class SonosSpeaker: if int_var in variables: setattr(self, int_var, variables[int_var]) + for enum_var in (ATTR_DIALOG_LEVEL,): + if enum_var in variables: + setattr(self, f"{enum_var}_enum", variables[enum_var]) + self.async_write_entity_states() # @@ -1172,8 +1188,15 @@ class SonosSpeaker: while not _test_groups(groups): await config_entry.runtime_data.topology_condition.wait() except TimeoutError: - _LOGGER.warning("Timeout waiting for target groups %s", groups) - + group_description = [ + f"{group[0].zone_name}: {', '.join(speaker.zone_name for speaker in group)}" + for group in groups + ] + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_join", + translation_placeholders={"group_description": str(group_description)}, + ) from TimeoutError any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 433bb3cc36a..adb233519b2 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -50,9 +50,29 @@ "name": "Music surround level" } }, + "select": { + "speech_dialog_level": { + "name": "Speech enhancement", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "max": "Max" + } + } + }, "sensor": { "audio_input_format": { "name": "Audio input format" + }, + "power_source": { + "name": "Power source", + "state": { + "battery": "Battery", + "charging_base": "Charging base", + "usb": "USB" + } } }, "switch": { @@ -194,6 +214,18 @@ }, "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" + }, + "entity_not_found": { + "message": "Entity {entity_id} not found." + }, + "speaker_not_found": { + "message": "{entity_id} is not a known Sonos speaker." + }, + "timeout_join": { + "message": "Timeout while waiting for Sonos player to join the group {group_description}" + }, + "invalid_alarm_id": { + "message": "Alarm {alarm_id} does not exist and cannot be updated." } } } diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 582845d10a2..653be229b22 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -19,7 +19,9 @@ from homeassistant.helpers.event import async_track_time_change from .alarms import SonosAlarms from .const import ( + ATTR_SPEECH_ENHANCEMENT_ENABLED, DOMAIN, + MODEL_SONOS_ARC_ULTRA, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, @@ -59,6 +61,7 @@ ALL_FEATURES = ( ATTR_SURROUND_ENABLED, ATTR_STATUS_LIGHT, ) +ALL_SUBST_FEATURES = (ATTR_SPEECH_ENHANCEMENT_ENABLED,) COORDINATOR_FEATURES = ATTR_CROSSFADE @@ -69,6 +72,14 @@ POLL_REQUIRED = ( WEEKEND_DAYS = (0, 6) +# Mapping of model names to feature attributes that need to be substituted. +# This is used to handle differences in attributes across Sonos models. +MODEL_FEATURE_SUBSTITUTIONS: dict[str, dict[str, str]] = { + MODEL_SONOS_ARC_ULTRA: { + ATTR_SPEECH_ENHANCEMENT: ATTR_SPEECH_ENHANCEMENT_ENABLED, + }, +} + async def async_setup_entry( hass: HomeAssistant, @@ -92,6 +103,13 @@ async def async_setup_entry( def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: features = [] + for feature_type in ALL_SUBST_FEATURES: + try: + if (state := getattr(speaker.soco, feature_type, None)) is not None: + setattr(speaker, feature_type, state) + except SoCoSlaveException: + pass + for feature_type in ALL_FEATURES: try: if (state := getattr(speaker.soco, feature_type, None)) is not None: @@ -107,12 +125,23 @@ async def async_setup_entry( available_soco_attributes, speaker ) for feature_type in available_features: + attribute_key = MODEL_FEATURE_SUBSTITUTIONS.get( + speaker.model_name.upper(), {} + ).get(feature_type, feature_type) _LOGGER.debug( - "Creating %s switch on %s", + "Creating %s switch on %s attribute %s", feature_type, speaker.zone_name, + attribute_key, + ) + entities.append( + SonosSwitchEntity( + feature_type=feature_type, + attribute_key=attribute_key, + speaker=speaker, + config_entry=config_entry, + ) ) - entities.append(SonosSwitchEntity(feature_type, speaker, config_entry)) async_add_entities(entities) config_entry.async_on_unload( @@ -127,11 +156,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): """Representation of a Sonos feature switch.""" def __init__( - self, feature_type: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry + self, + feature_type: str, + attribute_key: str, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, ) -> None: """Initialize the switch.""" super().__init__(speaker, config_entry) - self.feature_type = feature_type + self.attribute_key = attribute_key self.needs_coordinator = feature_type in COORDINATOR_FEATURES self._attr_entity_category = EntityCategory.CONFIG self._attr_translation_key = feature_type @@ -149,15 +182,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): @soco_error() def poll_state(self) -> None: """Poll the current state of the switch.""" - state = getattr(self.soco, self.feature_type) - setattr(self.speaker, self.feature_type, state) + state = getattr(self.soco, self.attribute_key) + setattr(self.speaker, self.attribute_key, state) @property def is_on(self) -> bool: """Return True if entity is on.""" if self.needs_coordinator and not self.speaker.is_coordinator: - return cast(bool, getattr(self.speaker.coordinator, self.feature_type)) - return cast(bool, getattr(self.speaker, self.feature_type)) + return cast(bool, getattr(self.speaker.coordinator, self.attribute_key)) + return cast(bool, getattr(self.speaker, self.attribute_key)) def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -175,7 +208,7 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): else: soco = self.soco try: - setattr(soco, self.feature_type, enable) + setattr(soco, self.attribute_key, enable) except SoCoUPnPException as exc: _LOGGER.warning("Could not toggle %s: %s", self.entity_id, exc) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index e4f439013c6..5f66ba380fe 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry( async_at_started(hass, _async_finish_startup) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True @@ -52,10 +51,3 @@ async def async_unload_entry( ) -> bool: """Unload SpeedTest Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: SpeedTestConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 4fbca5e0d29..4bae503f85e 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -6,7 +6,11 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from .const import ( @@ -45,7 +49,7 @@ class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data=user_input) -class SpeedTestOptionsFlowHandler(OptionsFlow): +class SpeedTestOptionsFlowHandler(OptionsFlowWithReload): """Handle SpeedTest options.""" def __init__(self) -> None: diff --git a/homeassistant/components/speedtestdotnet/coordinator.py b/homeassistant/components/speedtestdotnet/coordinator.py index 1308cb1d825..fac78a113f2 100644 --- a/homeassistant/components/speedtestdotnet/coordinator.py +++ b/homeassistant/components/speedtestdotnet/coordinator.py @@ -29,11 +29,10 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): api: speedtest.Speedtest, ) -> None: """Initialize the data object.""" - self.hass = hass self.api = api self.servers: dict[str, dict] = {DEFAULT_SERVER: {}} super().__init__( - self.hass, + hass, _LOGGER, config_entry=config_entry, name=DOMAIN, diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 80fcc777e73..ac7f575bcc5 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==0.8.11"] + "requirements": ["spotifyaio==1.0.0"] } diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index e3e6c699d03..33ed64be2bf 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -87,11 +87,6 @@ def remove_configured_db_url_if_not_needed( ) -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up SQL from yaml config.""" if (conf := config.get(DOMAIN)) is None: @@ -115,8 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: remove_configured_db_url_if_not_needed(hass, entry) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 4fe04f2401c..37a6f9ef104 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -209,7 +209,7 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SQLOptionsFlowHandler(OptionsFlow): +class SQLOptionsFlowHandler(OptionsFlowWithReload): """Handle SQL options.""" async def async_step_init( diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index b86a33db7ab..8c0ba81d6d2 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -401,9 +401,10 @@ class SQLSensor(ManualTriggerSensorEntity): if data is not None and self._template is not None: variables = self._template_variables_with_value(data) if self._render_availability_template(variables): - self._attr_native_value = self._template.async_render_as_value_template( + _value = self._template.async_render_as_value_template( self.entity_id, variables, None ) + self._set_native_value_with_possible_timestamp(_value) self._process_manual_data(variables) else: self._attr_native_value = data diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index f9b8044e992..cbc0deda96a 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -71,10 +71,13 @@ "selector": { "device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", @@ -85,6 +88,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -115,13 +119,14 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 596a44c498c..2bd845923fc 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,7 +1,7 @@ """The Squeezebox integration.""" from asyncio import timeout -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from http import HTTPStatus import logging @@ -37,10 +37,9 @@ from .const import ( DISCOVERY_INTERVAL, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, - MANUFACTURER, + SERVER_MANUFACTURER, SERVER_MODEL, + SERVER_MODEL_ID, SIGNAL_PLAYER_DISCOVERED, SIGNAL_PLAYER_REDISCOVERED, STATUS_API_TIMEOUT, @@ -72,6 +71,7 @@ class SqueezeboxData: coordinator: LMSStatusDataUpdateCoordinator server: Server + known_player_ids: set[str] = field(default_factory=set) type SqueezeboxConfigEntry = ConfigEntry[SqueezeboxData] @@ -112,9 +112,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - if not status: # pysqueezebox's async_query returns None on various issues, # including HTTP errors where it sets lms.http_status. - http_status = getattr(lms, "http_status", "N/A") - if http_status == HTTPStatus.UNAUTHORIZED: + if lms.http_status == HTTPStatus.UNAUTHORIZED: _LOGGER.warning("Authentication failed for Squeezebox server %s", host) raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -128,14 +127,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - _LOGGER.warning( "LMS %s returned no status or an error (HTTP status: %s). Retrying setup", host, - http_status, + lms.http_status, ) raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="init_get_status_failed", translation_placeholders={ "host": str(host), - "http_status": str(http_status), + "http_status": str(lms.http_status), }, ) @@ -173,8 +172,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - config_entry_id=entry.entry_id, identifiers={(DOMAIN, lms.uuid)}, name=lms.name, - manufacturer=MANUFACTURER, + manufacturer=SERVER_MANUFACTURER, model=SERVER_MODEL, + model_id=SERVER_MODEL_ID, sw_version=version, entry_type=DeviceEntryType.SERVICE, connections=mac_connect, @@ -185,16 +185,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms) - # set up player discovery - known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {}) - known_players = known_servers.setdefault(lms.uuid, {}).setdefault(KNOWN_PLAYERS, []) - async def _player_discovery(now: datetime | None = None) -> None: """Discover squeezebox players by polling server.""" async def _discovered_player(player: Player) -> None: """Handle a (re)discovered player.""" - if player.player_id in known_players: + if player.player_id in entry.runtime_data.known_player_ids: await player.async_update() async_dispatcher_send( hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected @@ -205,7 +201,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - hass, entry, player, lms.uuid ) await player_coordinator.async_refresh() - known_players.append(player.player_id) + entry.runtime_data.known_player_ids.add(player.player_id) async_dispatcher_send( hass, SIGNAL_PLAYER_DISCOVERED, player_coordinator ) diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index 1045e526ee3..ea305d71f99 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( class ServerStatusBinarySensor(LMSStatusEntity, BinarySensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 03df289a2fd..cebd4fcb04f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib from dataclasses import dataclass, field +import logging from typing import Any from pysqueezebox import Player @@ -21,6 +22,8 @@ from homeassistant.helpers.network import is_internal_request from .const import DOMAIN, UNPLAYABLE_TYPES +_LOGGER = logging.getLogger(__name__) + LIBRARY = [ "favorites", "artists", @@ -138,18 +141,44 @@ class BrowseData: self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE) self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) + def add_new_command(self, cmd: str | MediaType, type: str) -> None: + """Add items to maps for new apps or radios.""" + self.known_apps_radios.add(cmd) + self.media_type_to_squeezebox[cmd] = cmd + self.squeezebox_id_by_type[cmd] = type + self.content_type_media_class[cmd] = { + "item": MediaClass.DIRECTORY, + "children": MediaClass.TRACK, + } + self.content_type_to_child_type[cmd] = MediaType.TRACK -def _add_new_command_to_browse_data( - browse_data: BrowseData, cmd: str | MediaType, type: str -) -> None: - """Add items to maps for new apps or radios.""" - browse_data.media_type_to_squeezebox[cmd] = cmd - browse_data.squeezebox_id_by_type[cmd] = type - browse_data.content_type_media_class[cmd] = { - "item": MediaClass.DIRECTORY, - "children": MediaClass.TRACK, - } - browse_data.content_type_to_child_type[cmd] = MediaType.TRACK + async def async_init(self, player: Player, browse_limit: int) -> None: + """Initialize known apps and radios from the player.""" + + cmd = ["apps", 0, browse_limit] + result = await player.async_query(*cmd) + if result and result.get("appss_loop"): + for app in result["appss_loop"]: + app_cmd = "app-" + app["cmd"] + if app_cmd not in self.known_apps_radios: + self.add_new_command(app_cmd, "item_id") + _LOGGER.debug( + "Adding new command %s to browse data for player %s", + app_cmd, + player.player_id, + ) + cmd = ["radios", 0, browse_limit] + result = await player.async_query(*cmd) + if result and result.get("radioss_loop"): + for app in result["radioss_loop"]: + app_cmd = "app-" + app["cmd"] + if app_cmd not in self.known_apps_radios: + self.add_new_command(app_cmd, "item_id") + _LOGGER.debug( + "Adding new command %s to browse data for player %s", + app_cmd, + player.player_id, + ) def _build_response_apps_radios_category( @@ -221,12 +250,16 @@ def _get_item_thumbnail( ) -> str | None: """Construct path to thumbnail image.""" item_thumbnail: str | None = None - if artwork_track_id := item.get("artwork_track_id"): + track_id = item.get("artwork_track_id") or ( + item.get("id") if item_type == "track" else None + ) + + if track_id: if internal_request: - item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + item_thumbnail = player.generate_image_url_from_track_id(track_id) elif item_type is not None: item_thumbnail = entity.get_browse_image_url( - item_type, item["id"], artwork_track_id + item_type, item["id"], track_id ) elif search_type in ["apps", "radios"]: @@ -288,8 +321,7 @@ async def build_item_response( app_cmd = "app-" + item["cmd"] if app_cmd not in browse_data.known_apps_radios: - browse_data.known_apps_radios.add(app_cmd) - _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + browse_data.add_new_command(app_cmd, "item_id") child_media = _build_response_apps_radios_category( browse_data=browse_data, cmd=app_cmd, item=item @@ -311,8 +343,7 @@ async def build_item_response( title=item["title"], media_content_type=item_type, media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], - can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] - is not None, + can_expand=bool(CONTENT_TYPE_MEDIA_CLASS[item_type]["children"]), can_play=True, ) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 92eb3736341..091ef4d1bbd 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -4,12 +4,11 @@ CONF_HTTPS = "https" DISCOVERY_TASK = "discovery_task" DOMAIN = "squeezebox" DEFAULT_PORT = 9000 -KNOWN_PLAYERS = "known_players" -KNOWN_SERVERS = "known_servers" -MANUFACTURER = "https://lyrion.org/" PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" SENSOR_UPDATE_INTERVAL = 60 +SERVER_MANUFACTURER = "https://lyrion.org/" SERVER_MODEL = "Lyrion Music Server" +SERVER_MODEL_ID = "LMS" STATUS_API_TIMEOUT = 10 STATUS_SENSOR_LASTSCAN = "lastscan" STATUS_SENSOR_NEEDSRESTART = "needsrestart" diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 6582f143e79..9508420ec5f 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -30,7 +30,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): +class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """LMS Status custom coordinator.""" config_entry: SqueezeboxConfigEntry @@ -59,13 +59,13 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): else: _LOGGER.warning("Can't query server capabilities %s", self.lms.name) - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from LMS status call. Then we process only a subset to make then nice for HA """ async with timeout(STATUS_API_TIMEOUT): - data: dict | None = await self.lms.async_prepared_status() + data: dict[str, Any] | None = await self.lms.async_prepared_status() if not data: raise UpdateFailed( @@ -111,7 +111,7 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Only update players available at last update, unavailable players are rediscovered instead await self.player.async_update() - if self.player.connected is False: + if not self.player.connected: _LOGGER.info("Player %s is not available", self.name) self.available = False diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index 95fd2d60461..f2be716320f 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -26,11 +26,7 @@ class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]): self._player = coordinator.player self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, format_mac(self._player.player_id))}, - name=self._player.name, connections={(CONNECTION_NETWORK_MAC, format_mac(self._player.player_id))}, - via_device=(DOMAIN, coordinator.server_uuid), - model=self._player.model, - manufacturer=self._player.creator, ) @property diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index b29e19c1e3c..a857602a584 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -33,11 +33,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, discovery_flow, entity_platform, entity_registry as er, ) -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start @@ -59,8 +60,9 @@ from .const import ( DEFAULT_VOLUME_STEP, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, + SERVER_MANUFACTURER, + SERVER_MODEL, + SERVER_MODEL_ID, SIGNAL_PLAYER_DISCOVERED, SQUEEZEBOX_SOURCE_STRINGS, ) @@ -125,9 +127,52 @@ async def async_setup_entry( """Set up the Squeezebox media_player platform from a server config entry.""" # Add media player entities when discovered - async def _player_discovered(player: SqueezeBoxPlayerUpdateCoordinator) -> None: - _LOGGER.debug("Setting up media_player entity for player %s", player) - async_add_entities([SqueezeBoxMediaPlayerEntity(player)]) + async def _player_discovered( + coordinator: SqueezeBoxPlayerUpdateCoordinator, + ) -> None: + player = coordinator.player + _LOGGER.debug("Setting up media_player device and entity for player %s", player) + device_registry = dr.async_get(hass) + server_device = device_registry.async_get_device( + identifiers={(DOMAIN, coordinator.server_uuid)}, + ) + + name = player.name + model = player.model + manufacturer = player.creator + model_id = player.model_type + sw_version = "" + # Why? so we nicely merge with a server and a player linked by a MAC server is not all info lost + if ( + server_device + and (CONNECTION_NETWORK_MAC, format_mac(player.player_id)) + in server_device.connections + ): + _LOGGER.debug("Shared server & player device %s", server_device) + name = server_device.name + sw_version = server_device.sw_version or sw_version + model = SERVER_MODEL + "/" + model if model else SERVER_MODEL + manufacturer = ( + SERVER_MANUFACTURER + " / " + manufacturer + if manufacturer + else SERVER_MANUFACTURER + ) + model_id = SERVER_MODEL_ID + "/" + model_id if model_id else SERVER_MODEL_ID + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, player.player_id)}, + connections={(CONNECTION_NETWORK_MAC, player.player_id)}, + name=name, + model=model, + manufacturer=manufacturer, + model_id=model_id, + hw_version=player.firmware, + sw_version=sw_version, + via_device=(DOMAIN, coordinator.server_uuid), + ) + _LOGGER.debug("Creating / Updating player device %s", device) + async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)]) entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) @@ -181,10 +226,7 @@ def get_announce_timeout(extra: dict) -> int | None: class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): - """Representation of the media player features of a SqueezeBox device. - - Wraps a pysqueezebox.Player() object. - """ + """Representation of the media player features of a SqueezeBox device.""" _attr_supported_features = ( MediaPlayerEntityFeature.BROWSE_MEDIA @@ -241,9 +283,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): @property def browse_limit(self) -> int: - """Return the step to be used for volume up down.""" - return self.coordinator.config_entry.options.get( - CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + """Return the max number of items to return from browse.""" + return int( + self.coordinator.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ) ) @property @@ -267,17 +311,22 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): ) return None + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + await self._browse_data.async_init(self._player, self.browse_limit) + async def async_will_remove_from_hass(self) -> None: """Remove from list of known players when removed from hass.""" - known_servers = self.hass.data[DOMAIN][KNOWN_SERVERS] - known_players = known_servers[self.coordinator.server_uuid][KNOWN_PLAYERS] - known_players.remove(self.coordinator.player.player_id) + self.coordinator.config_entry.runtime_data.known_player_ids.remove( + self.coordinator.player.player_id + ) @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._player.volume: - return int(float(self._player.volume)) / 100.0 + if self._player.volume is not None: + return float(self._player.volume) / 100.0 return None @@ -386,7 +435,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - volume_percent = str(int(volume * 100)) + volume_percent = str(round(volume * 100)) await self._player.async_set_volume(volume_percent) await self.coordinator.async_refresh() diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 11c169910dc..79390910ef7 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( class ServerStatusSensor(LMSStatusEntity, SensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 93943b0a9ea..2471e45b4e0 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.44.0"] + "requirements": ["async-upnp-client==0.45.0"] } diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index f8846c2a97f..f940971c15c 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -2,8 +2,6 @@ from __future__ import annotations -from collections.abc import Callable - from homeassistant.helpers.entity import Entity from .account import StarlineAccount, StarlineDevice @@ -24,7 +22,6 @@ class StarlineEntity(Entity): self._key = key self._attr_unique_id = f"starline-{key}-{device.device_id}" self._attr_device_info = account.device_info(device) - self._unsubscribe_api: Callable | None = None @property def available(self) -> bool: @@ -38,11 +35,4 @@ class StarlineEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" await super().async_added_to_hass() - self._unsubscribe_api = self._account.api.add_update_listener(self.update) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity is being removed from Home Assistant.""" - await super().async_will_remove_from_hass() - if self._unsubscribe_api is not None: - self._unsubscribe_api() - self._unsubscribe_api = None + self.async_on_remove(self._account.api.add_update_listener(self.update)) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 916d0a9f26b..d87c2eed304 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -62,7 +62,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="fuel", translation_key="fuel", device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="errors", diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 14cbf6fe876..b353051a074 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -114,7 +114,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: ( - now() - timedelta(seconds=data.status["uptime"]) + now() - timedelta(seconds=data.status["uptime"], milliseconds=-500) ).replace(microsecond=0), ), StarlinkSensorEntityDescription( diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index f800c82f1f9..34799e366d1 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -1,5 +1,7 @@ """The statistics component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,15 +9,21 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Statistics from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -36,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -52,6 +61,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the statistics config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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 + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Statistics config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index fb8c09868d5..d9ff172e0a4 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -161,6 +161,8 @@ OPTIONS_FLOW = { class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Statistics.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -234,15 +236,15 @@ async def ws_start_preview( ) preview_entity = StatisticsSensor( hass, - entity_id, - name, - None, - state_characteristic, - sampling_size, - max_age, - msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), - msg["user_input"].get(CONF_PRECISION), - msg["user_input"].get(CONF_PERCENTILE), + source_entity_id=entity_id, + name=name, + unique_id=None, + state_characteristic=state_characteristic, + samples_max_buffer_size=sampling_size, + samples_max_age=max_age, + samples_keep_last=msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), + precision=msg["user_input"].get(CONF_PRECISION), + percentile=msg["user_input"].get(CONF_PERCENTILE), ) preview_entity.hass = hass diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index a5c5f10ecd0..14471ab16ee 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -46,7 +46,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -659,6 +659,7 @@ class StatisticsSensor(SensorEntity): def __init__( self, hass: HomeAssistant, + *, source_entity_id: str, name: str, unique_id: str | None, @@ -673,10 +674,11 @@ class StatisticsSensor(SensorEntity): self._attr_name: str = name self._attr_unique_id: str | None = unique_id self._source_entity_id: str = source_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self.is_binary: bool = ( split_entity_id(self._source_entity_id)[0] == BINARY_SENSOR_DOMAIN ) @@ -725,12 +727,11 @@ class StatisticsSensor(SensorEntity): def _async_handle_new_state( self, - reported_state: State | None, + reported_state: State, + timestamp: float, ) -> None: """Handle the sensor state changes.""" - if (new_state := reported_state) is None: - return - self._add_state_to_queue(new_state) + self._add_state_to_queue(reported_state, timestamp) self._async_purge_update_and_schedule() if self._preview_callback: @@ -745,14 +746,18 @@ class StatisticsSensor(SensorEntity): self, event: Event[EventStateChangedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + if (new_state := event.data["new_state"]) is None: + return + self._async_handle_new_state(new_state, new_state.last_updated_timestamp) @callback def _async_stats_sensor_state_report_listener( self, event: Event[EventStateReportedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + self._async_handle_new_state( + event.data["new_state"], event.data["last_reported"].timestamp() + ) async def _async_stats_sensor_startup(self) -> None: """Add listener and get recorded state. @@ -783,7 +788,9 @@ class StatisticsSensor(SensorEntity): """Register callbacks.""" await self._async_stats_sensor_startup() - def _add_state_to_queue(self, new_state: State) -> None: + def _add_state_to_queue( + self, new_state: State, last_reported_timestamp: float + ) -> None: """Add the state to the queue.""" # Attention: it is not safe to store the new_state object, @@ -803,7 +810,7 @@ class StatisticsSensor(SensorEntity): self.states.append(new_state.state == "on") else: self.states.append(float(new_state.state)) - self.ages.append(new_state.last_reported_timestamp) + self.ages.append(last_reported_timestamp) self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False @@ -1060,7 +1067,7 @@ class StatisticsSensor(SensorEntity): self._fetch_states_from_database ): for state in reversed(states): - self._add_state_to_queue(state) + self._add_state_to_queue(state, state.last_reported_timestamp) self._calculate_state_attributes(state) self._async_purge_update_and_schedule() diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index f8140ed36d7..7418c5b7b32 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], - "requirements": ["pystiebeleltron==0.1.0"] + "requirements": ["pystiebeleltron==0.2.3"] } diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index 9adfc09de0e..e51f3d76c7c 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -8,13 +8,27 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, 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 .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator +from .services import setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Stookwijzer component.""" + setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 1b0be86d375..65b20949fe1 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -5,3 +5,5 @@ from typing import Final DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) + +SERVICE_GET_FORECAST = "get_forecast" diff --git a/homeassistant/components/stookwijzer/icons.json b/homeassistant/components/stookwijzer/icons.json new file mode 100644 index 00000000000..19fda370796 --- /dev/null +++ b/homeassistant/components/stookwijzer/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_forecast": { + "service": "mdi:clock-plus-outline" + } + } +} diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py new file mode 100644 index 00000000000..1543d7e8777 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.py @@ -0,0 +1,77 @@ +"""Define services for the Stookwijzer integration.""" + +from typing import Required, TypedDict, cast + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError + +from .const import DOMAIN, SERVICE_GET_FORECAST +from .coordinator import StookwijzerConfigEntry + +SERVICE_GET_FORECAST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + } +) + + +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + +def async_get_entry( + hass: HomeAssistant, config_entry_id: str +) -> StookwijzerConfigEntry: + """Get the Overseerr config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(StookwijzerConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Stookwijzer integration.""" + + async def async_get_forecast(call: ServiceCall) -> ServiceResponse | None: + """Get the forecast from API endpoint.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + + return cast( + ServiceResponse, + { + "forecast": cast( + list[Forecast], await client.async_get_forecast() or [] + ), + }, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECAST, + async_get_forecast, + schema=SERVICE_GET_FORECAST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/stookwijzer/services.yaml b/homeassistant/components/stookwijzer/services.yaml new file mode 100644 index 00000000000..49e1f7b2927 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.yaml @@ -0,0 +1,7 @@ +get_forecast: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: stookwijzer diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index a028f1f19c5..160387ed8aa 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -27,6 +27,18 @@ } } }, + "services": { + "get_forecast": { + "name": "Get forecast", + "description": "Retrieves the advice forecast from Stookwijzer.", + "fields": { + "config_entry_id": { + "name": "Stookwijzer instance", + "description": "The Stookwijzer instance to get the forecast from." + } + } + } + }, "issues": { "location_migration_failed": { "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", @@ -36,6 +48,12 @@ "exceptions": { "no_data_received": { "message": "No data received from Stookwijzer." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } } } diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 9426b5b04de..a31ce433c06 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -55,6 +55,7 @@ from .const import ( MAX_SEGMENTS, OUTPUT_FORMATS, OUTPUT_IDLE_TIMEOUT, + OUTPUT_STARTUP_TIMEOUT, RECORDER_PROVIDER, RTSP_TRANSPORTS, SEGMENT_DURATION_ADJUSTER, @@ -363,11 +364,14 @@ class Stream: # without concern about self._outputs being modified from another thread. return MappingProxyType(self._outputs.copy()) - def add_provider( - self, fmt: str, timeout: int = OUTPUT_IDLE_TIMEOUT - ) -> StreamOutput: + def add_provider(self, fmt: str, timeout: int | None = None) -> StreamOutput: """Add provider output stream.""" if not (provider := self._outputs.get(fmt)): + startup_timeout = OUTPUT_STARTUP_TIMEOUT + if timeout is None: + timeout = OUTPUT_IDLE_TIMEOUT + else: + startup_timeout = timeout async def idle_callback() -> None: if ( @@ -379,7 +383,7 @@ class Stream: provider = PROVIDERS[fmt]( self.hass, - IdleTimer(self.hass, timeout, idle_callback), + IdleTimer(self.hass, timeout, idle_callback, startup_timeout), self._stream_settings, self.dynamic_stream_settings, ) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index c81d2f6cb18..df50ecefd62 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -22,7 +22,8 @@ AUDIO_CODECS = {"aac", "mp3"} FORMAT_CONTENT_TYPE = {HLS_PROVIDER: "application/vnd.apple.mpegurl"} -OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity +OUTPUT_STARTUP_TIMEOUT = 60 # timeout due to no startup +OUTPUT_IDLE_TIMEOUT = 30 # Idle timeout due to inactivity NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist MAX_SEGMENTS = 5 # Max number of segments to keep around diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 44dfe2c323d..7dc6bab16b9 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -234,10 +234,12 @@ class IdleTimer: hass: HomeAssistant, timeout: int, idle_callback: Callable[[], Coroutine[Any, Any, None]], + startup_timeout: int | None = None, ) -> None: """Initialize IdleTimer.""" self._hass = hass self._timeout = timeout + self._startup_timeout = startup_timeout or timeout self._callback = idle_callback self._unsub: CALLBACK_TYPE | None = None self.idle = False @@ -246,7 +248,7 @@ class IdleTimer: """Start the idle timer if not already started.""" self.idle = False if self._unsub is None: - self._unsub = async_call_later(self._hass, self._timeout, self.fire) + self._unsub = async_call_later(self._hass, self._startup_timeout, self.fire) def awake(self) -> None: """Keep the idle time alive by resetting the timeout.""" diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 6eaee7f1534..8ba8904751e 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.3.0"] + "requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.3.2"] } diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 6aef0041874..e2399344544 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -102,11 +102,11 @@ "services": { "unlock_specific_door": { "name": "Unlock specific door", - "description": "Unlocks specific door(s).", + "description": "Unlocks the driver door, all doors, or the tailgate.", "fields": { "door": { "name": "Door", - "description": "Which door(s) to open." + "description": "The specific door(s) to unlock." } } } diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 9149f216563..5c23240ce91 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.5"] + "requirements": ["pysuezV2==2.0.7"] } diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 205f1bb8b5c..415d0a04e7c 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_CONDITION, SUN_EVENT_SUNRISE, SUN_EVENT_SUN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( + Condition, ConditionCheckerType, condition_trace_set_result, condition_trace_update_result, @@ -37,13 +38,6 @@ _CONDITION_SCHEMA = vol.All( ) -async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate config.""" - return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] - - def sun( hass: HomeAssistant, before: str | None = None, @@ -128,16 +122,41 @@ def sun( return True -def async_condition_from_config(config: ConfigType) -> ConditionCheckerType: - """Wrap action method with sun based condition.""" - before = config.get("before") - after = config.get("after") - before_offset = config.get("before_offset") - after_offset = config.get("after_offset") +class SunCondition(Condition): + """Sun condition.""" - @trace_condition_function - def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Validate time based if-condition.""" - return sun(hass, before, after, before_offset, after_offset) + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + self._hass = hass - return sun_if + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + + async def async_get_checker(self) -> ConditionCheckerType: + """Wrap action method with sun based condition.""" + before = self._config.get("before") + after = self._config.get("after") + before_offset = self._config.get("before_offset") + after_offset = self._config.get("after_offset") + + @trace_condition_function + def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Validate time based if-condition.""" + return sun(hass, before, after, before_offset, after_offset) + + return sun_if + + +CONDITIONS: dict[str, type[Condition]] = { + "_": SunCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index 10bfc0d0355..c6637adbbef 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -29,7 +29,6 @@ PLACEHOLDERS = { "opendata_url": "http://transport.opendata.ch", } -ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" ATTR_LIMIT: Final = "limit" SERVICE_FETCH_CONNECTIONS = "fetch_connections" diff --git a/homeassistant/components/swiss_public_transport/services.py b/homeassistant/components/swiss_public_transport/services.py index 1ac116b4ca9..9297bd4b409 100644 --- a/homeassistant/components/swiss_public_transport/services.py +++ b/homeassistant/components/swiss_public_transport/services.py @@ -3,6 +3,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -19,7 +20,6 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONNECTIONS_COUNT, CONNECTIONS_MAX, diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index c77eda9b294..b511e2af2b2 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -10,8 +10,11 @@ from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_INVERT, CONF_TARGET_DOMAIN @@ -19,24 +22,14 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_add_to_device( - hass: HomeAssistant, entry: ConfigEntry, entity_id: str -) -> str | None: - """Add our config entry to the tracked entity's device.""" +def async_get_parent_device_id(hass: HomeAssistant, entity_id: str) -> str | None: + """Get the parent device id.""" registry = er.async_get(hass) - device_registry = dr.async_get(hass) - device_id = None - if ( - not (wrapped_switch := registry.async_get(entity_id)) - or not (device_id := wrapped_switch.device_id) - or not (device_registry.async_get(device_id)) - ): - return device_id + if not (wrapped_switch := registry.async_get(entity_id)): + return None - device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id) - - return device_id + return wrapped_switch.device_id async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -68,9 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, - source_device_id=async_add_to_device(hass, entry, entity_id), + source_device_id=async_get_parent_device_id(hass, entity_id), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], source_entity_removed=source_entity_removed, ) @@ -96,8 +90,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> options = {**config_entry.options} if config_entry.minor_version < 2: options.setdefault(CONF_INVERT, False) + if config_entry.version < 3: + # Remove the switch_as_x config entry from the source device + if source_device_id := async_get_parent_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) 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/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index aa9f1d411ce..cf442256cbe 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -58,7 +58,7 @@ class SwitchAsXConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title and hide the wrapped entity if registered.""" diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index 64bfe712086..7611725d457 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -15,7 +15,6 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event @@ -48,12 +47,8 @@ class BaseEntity(Entity): if wrapped_switch: name = wrapped_switch.original_name - self._device_id = device_id if device_id and (device := device_registry.async_get(device_id)): - self._attr_device_info = DeviceInfo( - connections=device.connections, - identifiers=device.identifiers, - ) + self.device_entry = device self._attr_entity_category = entity_category self._attr_has_entity_name = has_entity_name self._attr_name = name diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index af4001f0d9a..acf37fe916b 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -92,6 +92,9 @@ PLATFORMS_BY_TYPE = { ], SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR], SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR], + SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR], + SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -117,6 +120,9 @@ CLASS_BY_DEVICE = { SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock, SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier, + SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, + SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3, + SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, } diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 82e6e43130b..b207440d796 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -367,8 +367,12 @@ class SwitchbotOptionsFlowHandler(OptionsFlow): ), ): int } - if self.config_entry.data.get(CONF_SENSOR_TYPE, "").startswith( - SupportedModels.LOCK + if CONF_SENSOR_TYPE in self.config_entry.data and self.config_entry.data[ + CONF_SENSOR_TYPE + ] in ( + SupportedModels.LOCK, + SupportedModels.LOCK_PRO, + SupportedModels.LOCK_ULTRA, ): options.update( { diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index f6536ca3ff3..c57b8d467cc 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -48,6 +48,9 @@ class SupportedModels(StrEnum): LOCK_ULTRA = "lock_ultra" AIR_PURIFIER = "air_purifier" AIR_PURIFIER_TABLE = "air_purifier_table" + EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier" + FLOOR_LAMP = "floor_lamp" + STRIP_LIGHT_3 = "strip_light_3" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -75,6 +78,9 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA, SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE, + SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER, + SwitchbotModel.FLOOR_LAMP: SupportedModels.FLOOR_LAMP, + SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -103,6 +109,9 @@ ENCRYPTED_MODELS = { SwitchbotModel.LOCK_ULTRA, SwitchbotModel.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE, + SwitchbotModel.EVAPORATIVE_HUMIDIFIER, + SwitchbotModel.FLOOR_LAMP, + SwitchbotModel.STRIP_LIGHT_3, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -116,6 +125,9 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock, SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier, SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier, + SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, + SwitchbotModel.FLOOR_LAMP: switchbot.SwitchbotStripLight3, + SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index c15cf7ac9c6..c162f4947ed 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -2,11 +2,16 @@ from __future__ import annotations +import logging +from typing import Any + import switchbot +from switchbot import HumidifierAction as SwitchbotHumidifierAction, HumidifierMode from homeassistant.components.humidifier import ( MODE_AUTO, MODE_NORMAL, + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -17,7 +22,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry from .entity import SwitchbotSwitchedEntity, exception_handler +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +EVAPORATIVE_HUMIDIFIER_ACTION_MAP: dict[int, HumidifierAction] = { + SwitchbotHumidifierAction.OFF: HumidifierAction.OFF, + SwitchbotHumidifierAction.HUMIDIFYING: HumidifierAction.HUMIDIFYING, + SwitchbotHumidifierAction.DRYING: HumidifierAction.DRYING, +} async def async_setup_entry( @@ -26,7 +37,11 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" - async_add_entities([SwitchBotHumidifier(entry.runtime_data)]) + coordinator = entry.runtime_data + if isinstance(coordinator.device, switchbot.SwitchbotEvaporativeHumidifier): + async_add_entities([SwitchBotEvaporativeHumidifier(coordinator)]) + else: + async_add_entities([SwitchBotHumidifier(coordinator)]) class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): @@ -69,3 +84,71 @@ class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): else: self._last_run_success = await self._device.async_set_manual() self.async_write_ha_state() + + +class SwitchBotEvaporativeHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): + """Representation of a Switchbot evaporative humidifier.""" + + _device: switchbot.SwitchbotEvaporativeHumidifier + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_available_modes = HumidifierMode.get_modes() + _attr_min_humidity = 1 + _attr_max_humidity = 99 + _attr_translation_key = "evaporative_humidifier" + _attr_name = None + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self._device.is_on() + + @property + def mode(self) -> str: + """Return the evaporative humidifier current mode.""" + return self._device.get_mode().name.lower() + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self._device.get_humidity() + + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + return self._device.get_target_humidity() + + @property + def action(self) -> HumidifierAction | None: + """Return the current action.""" + return EVAPORATIVE_HUMIDIFIER_ACTION_MAP.get( + self._device.get_action(), HumidifierAction.IDLE + ) + + @exception_handler + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + _LOGGER.debug("Setting target humidity to: %s %s", humidity, self._address) + await self._device.set_target_humidity(humidity) + self.async_write_ha_state() + + @exception_handler + async def async_set_mode(self, mode: str) -> None: + """Set new evaporative humidifier mode.""" + _LOGGER.debug("Setting mode to: %s %s", mode, self._address) + await self._device.set_mode(HumidifierMode[mode.upper()]) + self.async_write_ha_state() + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the humidifier.""" + _LOGGER.debug("Turning on the humidifier %s", self._address) + await self._device.turn_on() + self.async_write_ha_state() + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the humidifier.""" + _LOGGER.debug("Turning off the humidifier %s", self._address) + await self._device.turn_off() + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 9dd46e0717a..cf9217bf70b 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -1,5 +1,31 @@ { "entity": { + "sensor": { + "light_level": { + "default": "mdi:brightness-7", + "state": { + "1": "mdi:brightness-1", + "2": "mdi:brightness-1", + "3": "mdi:brightness-2", + "4": "mdi:brightness-3", + "5": "mdi:brightness-4", + "6": "mdi:brightness-5", + "7": "mdi:brightness-5", + "8": "mdi:brightness-6", + "9": "mdi:brightness-6", + "10": "mdi:brightness-7" + } + }, + "water_level": { + "default": "mdi:water-percent", + "state": { + "empty": "mdi:water-off", + "low": "mdi:water-outline", + "medium": "mdi:water", + "high": "mdi:water-check" + } + } + }, "fan": { "fan": { "state_attributes": { @@ -31,6 +57,53 @@ } } } + }, + "humidifier": { + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "mdi:water-plus", + "medium": "mdi:water", + "low": "mdi:water-outline", + "quiet": "mdi:volume-off", + "target_humidity": "mdi:target", + "sleep": "mdi:weather-night", + "auto": "mdi:autorenew", + "drying_filter": "mdi:water-remove" + } + } + } + } + }, + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "christmas": "mdi:string-lights", + "halloween": "mdi:halloween", + "sunset": "mdi:weather-sunset", + "vitality": "mdi:parachute", + "flashing": "mdi:flash", + "strobe": "mdi:led-strip-variant", + "fade": "mdi:water-opacity", + "smooth": "mdi:led-strip-variant", + "forest": "mdi:forest", + "ocean": "mdi:waves", + "autumn": "mdi:leaf-maple", + "cool": "mdi:emoticon-cool-outline", + "flow": "mdi:pulse", + "relax": "mdi:coffee", + "modern": "mdi:school-outline", + "rose": "mdi:flower", + "colorful": "mdi:looks", + "flickering": "mdi:led-strip-variant", + "breathing": "mdi:heart-pulse" + } + } + } + } } } } diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index ad37f3ebec0..e9a3518498d 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any, cast import switchbot @@ -10,14 +11,16 @@ from switchbot import ColorMode as SwitchBotColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityFeature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .coordinator import SwitchbotConfigEntry from .entity import SwitchbotEntity, exception_handler SWITCHBOT_COLOR_MODE_TO_HASS = { @@ -25,6 +28,7 @@ SWITCHBOT_COLOR_MODE_TO_HASS = { SwitchBotColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP, } +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -42,34 +46,69 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): _device: switchbot.SwitchbotBaseLight _attr_name = None + _attr_translation_key = "light" - def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: - """Initialize the Switchbot light.""" - super().__init__(coordinator) - device = self._device - self._attr_max_color_temp_kelvin = device.max_temp - self._attr_min_color_temp_kelvin = device.min_temp - self._attr_supported_color_modes = { - SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in device.color_modes - } - self._async_update_attrs() + @property + def max_color_temp_kelvin(self) -> int: + """Return the max color temperature.""" + return self._device.max_temp - @callback - def _async_update_attrs(self) -> None: - """Handle updating _attr values.""" - device = self._device - self._attr_is_on = self._device.on - self._attr_brightness = max(0, min(255, round(device.brightness * 2.55))) - if device.color_mode == SwitchBotColorMode.COLOR_TEMP: - self._attr_color_temp_kelvin = device.color_temp - self._attr_color_mode = ColorMode.COLOR_TEMP - return - self._attr_rgb_color = device.rgb - self._attr_color_mode = ColorMode.RGB + @property + def min_color_temp_kelvin(self) -> int: + """Return the min color temperature.""" + return self._device.min_temp + + @property + def supported_color_modes(self) -> set[ColorMode]: + """Return the supported color modes.""" + return {SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in self._device.color_modes} + + @property + def supported_features(self) -> LightEntityFeature: + """Return the supported features.""" + return LightEntityFeature.EFFECT if self.effect_list else LightEntityFeature(0) + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + return max(0, min(255, round(self._device.brightness * 2.55))) + + @property + def color_mode(self) -> ColorMode | None: + """Return the color mode of the light.""" + return SWITCHBOT_COLOR_MODE_TO_HASS.get( + self._device.color_mode, ColorMode.UNKNOWN + ) + + @property + def effect_list(self) -> list[str] | None: + """Return the list of effects supported by the light.""" + return self._device.get_effect_list + + @property + def effect(self) -> str | None: + """Return the current effect of the light.""" + return self._device.get_effect() + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the RGB color of the light.""" + return self._device.rgb + + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature of the light.""" + return self._device.color_temp + + @property + def is_on(self) -> bool: + """Return true if the light is on.""" + return self._device.on @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" + _LOGGER.debug("Turning on light %s, address %s", kwargs, self._address) brightness = round( cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100 ) @@ -82,6 +121,10 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): kelvin = max(2700, min(6500, kwargs[ATTR_COLOR_TEMP_KELVIN])) await self._device.set_color_temp(brightness, kelvin) return + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + await self._device.set_effect(effect) + return if ATTR_RGB_COLOR in kwargs: rgb = kwargs[ATTR_RGB_COLOR] await self._device.set_rgb(brightness, rgb[0], rgb[1], rgb[2]) @@ -94,4 +137,5 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" + _LOGGER.debug("Turning off light %s, address %s", kwargs, self._address) await self._device.turn_off() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 78cd5276134..91d0c590e00 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.66.0"] + "requirements": ["PySwitchbot==0.68.4"] } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 736297ca091..9196453e98c 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from switchbot import HumidifierWaterLevel from switchbot.const.air_purifier import AirQualityLevel from homeassistant.components.bluetooth import async_last_service_info @@ -66,7 +67,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { "lightLevel": SensorEntityDescription( key="lightLevel", translation_key="light_level", - native_unit_of_measurement="Level", state_class=SensorStateClass.MEASUREMENT, ), "humidity": SensorEntityDescription( @@ -117,6 +117,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), + "water_level": SensorEntityDescription( + key="water_level", + translation_key="water_level", + device_class=SensorDeviceClass.ENUM, + options=HumidifierWaterLevel.get_levels(), + ), } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index c758ae645ae..35482016e90 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -34,7 +34,7 @@ } }, "encrypted_auth": { - "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case sensitive.", + "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case-sensitive.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -114,6 +114,15 @@ "moderate": "Moderate", "unhealthy": "Unhealthy" } + }, + "water_level": { + "name": "Water level", + "state": { + "empty": "[%key:common::state::empty%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } } }, "cover": { @@ -138,6 +147,22 @@ } } } + }, + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "[%key:common::state::high%]", + "medium": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", + "quiet": "Quiet", + "target_humidity": "Target humidity", + "sleep": "Sleep", + "auto": "[%key:common::state::auto%]", + "drying_filter": "Drying filter" + } + } + } } }, "lock": { @@ -181,7 +206,7 @@ }, "preset_mode": { "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "natural": "Natural", "sleep": "Sleep", "baby": "Baby" @@ -221,6 +246,35 @@ } } } + }, + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "christmas": "Christmas", + "halloween": "Halloween", + "sunset": "Sunset", + "vitality": "Vitality", + "flashing": "Flashing", + "strobe": "Strobe", + "fade": "Fade", + "smooth": "Smooth", + "forest": "Forest", + "ocean": "Ocean", + "autumn": "Autumn", + "cool": "Cool", + "flow": "Flow", + "relax": "Relax", + "modern": "Modern", + "rose": "Rose", + "colorful": "Colorful", + "flickering": "Flickering", + "breathing": "Breathing" + } + } + } + } } }, "exceptions": { diff --git a/homeassistant/components/switchbot/vacuum.py b/homeassistant/components/switchbot/vacuum.py index 9dade6b7f46..8535fdc7843 100644 --- a/homeassistant/components/switchbot/vacuum.py +++ b/homeassistant/components/switchbot/vacuum.py @@ -87,8 +87,7 @@ class SwitchbotVacuumEntity(SwitchbotEntity, StateVacuumEntity): _device: switchbot.SwitchbotVacuum _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START | VacuumEntityFeature.STATE ) @@ -108,11 +107,6 @@ class SwitchbotVacuumEntity(SwitchbotEntity, StateVacuumEntity): status_code = self._device.get_work_status() return SWITCHBOT_VACUUM_STATE_MAP[self.protocol_version].get(status_code) - @property - def battery_level(self) -> int: - """Return the vacuum battery.""" - return self._device.get_battery() - async def async_start(self) -> None: """Start or resume the cleaning task.""" self._last_run_success = bool( diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 7b7f60589f0..edf30984fe6 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -29,6 +29,9 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, Platform.LOCK, Platform.SENSOR, Platform.SWITCH, @@ -45,12 +48,15 @@ class SwitchbotDevices: ) buttons: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) climates: list[tuple[Remote, SwitchBotCoordinator]] = field(default_factory=list) + covers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field( default_factory=list ) sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -96,7 +102,6 @@ async def make_switchbot_devices( for device in devices ] ) - return devices_data @@ -141,19 +146,27 @@ async def make_device_data( hass, entry, api, device, coordinators_by_id ) devices_data.sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type in [ "K10+", "K10+ Pro", "Robot Vacuum Cleaner S1", "Robot Vacuum Cleaner S1 Plus", + "K20+ Pro", + "Robot Vacuum Cleaner K10+ Pro Combo", + "Robot Vacuum Cleaner S10", + "S20", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id, True ) devices_data.vacuums.append((device, coordinator)) - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + if isinstance(device, Device) and device.device_type in [ + "Smart Lock", + "Smart Lock Lite", + "Smart Lock Pro", + "Smart Lock Ultra", + ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id ) @@ -171,6 +184,53 @@ async def make_device_data( devices_data.buttons.append((device, coordinator)) else: devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type.startswith("Air Purifier"): + 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 [ + "Battery Circulator Fan", + "Circulator 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 in [ + "Curtain", + "Curtain3", + "Roller Shade", + "Blind Tilt", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.covers.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Garage Door Opener", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.covers.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Strip Light", + "Strip Light 3", + "Floor Lamp", + "Color Bulb", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.lights.append((device, coordinator)) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 752c428fa6c..a1ad6d6887d 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -48,10 +48,23 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Lite": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), "Smart Lock Pro": ( CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Ultra": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), + "Curtain": (CALIBRATION_DESCRIPTION,), + "Curtain3": (CALIBRATION_DESCRIPTION,), + "Roller Shade": (CALIBRATION_DESCRIPTION,), + "Blind Tilt": (CALIBRATION_DESCRIPTION,), + "Garage Door Opener": (DOOR_OPEN_DESCRIPTION,), } @@ -69,7 +82,6 @@ async def async_setup_entry( for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ device.device_type ] - if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index b849194537a..23a212075c4 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -1,6 +1,7 @@ """Constants for the SwitchBot Cloud integration.""" from datetime import timedelta +from enum import Enum from typing import Final DOMAIN: Final = "switchbot_cloud" @@ -15,3 +16,20 @@ VACUUM_FAN_SPEED_QUIET = "quiet" VACUUM_FAN_SPEED_STANDARD = "standard" VACUUM_FAN_SPEED_STRONG = "strong" VACUUM_FAN_SPEED_MAX = "max" + +AFTER_COMMAND_REFRESH = 5 +COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 + + +class AirPurifierMode(Enum): + """Air Purifier Modes.""" + + NORMAL = 1 + AUTO = 2 + SLEEP = 3 + PET = 4 + + @classmethod + def get_modes(cls) -> list[str]: + """Return a list of available air purifier modes as lowercase strings.""" + return [mode.name.lower() for mode in cls] diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py new file mode 100644 index 00000000000..77f0b960d25 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -0,0 +1,233 @@ +"""Support for the Switchbot BlindTilt, Curtain, Curtain3, RollerShade as Cover.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + BlindTiltCommands, + CommonCommands, + CurtainCommands, + Device, + Remote, + RollerShadeCommands, + SwitchBotAPI, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData, SwitchBotCoordinator +from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH, DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.covers + ) + + +class SwitchBotCloudCover(SwitchBotCloudEntity, CoverEntity): + """Representation of a SwitchBot Cover.""" + + _attr_name = None + _attr_is_closed: bool | None = None + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + position: int | None = self.coordinator.data.get("slidePosition") + if position is None: + return + self._attr_current_cover_position = 100 - position + self._attr_current_cover_tilt_position = 100 - position + self._attr_is_closed = position == 100 + + +class SwitchBotCloudCoverCurtain(SwitchBotCloudCover): + """Representation of a SwitchBot Curtain & Curtain3.""" + + _attr_device_class = CoverDeviceClass.CURTAIN + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position: int | None = kwargs.get("position") + if position is not None: + await self.send_api_command( + CurtainCommands.SET_POSITION, + parameters=f"{0},ff,{100 - position}", + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.send_api_command(CurtainCommands.PAUSE) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover): + """Representation of a SwitchBot RollerShade.""" + + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0)) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command( + RollerShadeCommands.SET_POSITION, parameters=str(100) + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position: int | None = kwargs.get("position") + if position is not None: + await self.send_api_command( + RollerShadeCommands.SET_POSITION, parameters=str(100 - position) + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverBlindTilt(SwitchBotCloudCover): + """Representation of a SwitchBot Blind Tilt.""" + + _attr_direction: str | None = None + _attr_device_class = CoverDeviceClass.BLIND + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + ) + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + position: int | None = self.coordinator.data.get("slidePosition") + if position is None: + return + self._attr_is_closed = position in [0, 100] + if position > 50: + percent = 100 - ((position - 50) * 2) + else: + percent = 100 - (50 - position) * 2 + self._attr_current_cover_position = percent + self._attr_current_cover_tilt_position = percent + direction = self.coordinator.data.get("direction") + self._attr_direction = direction.lower() if direction else None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + percent: int | None = kwargs.get("tilt_position") + if percent is not None: + await self.send_api_command( + BlindTiltCommands.SET_POSITION, + parameters=f"{self._attr_direction};{percent}", + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(BlindTiltCommands.FULLY_OPEN) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover.""" + if self._attr_direction is not None: + if "up" in self._attr_direction: + await self.send_api_command(BlindTiltCommands.CLOSE_UP) + else: + await self.send_api_command(BlindTiltCommands.CLOSE_DOWN) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverGarageDoorOpener(SwitchBotCloudCover): + """Representation of a SwitchBot Garage Door Opener.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + door_status: int | None = self.coordinator.data.get("doorStatus") + self._attr_is_closed = None if door_status is None else door_status == 1 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> ( + SwitchBotCloudCoverBlindTilt + | SwitchBotCloudCoverRollerShade + | SwitchBotCloudCoverCurtain + | SwitchBotCloudCoverGarageDoorOpener +): + """Make a SwitchBotCloudCover device.""" + if device.device_type == "Blind Tilt": + return SwitchBotCloudCoverBlindTilt(api, device, coordinator) + if device.device_type == "Roller Shade": + return SwitchBotCloudCoverRollerShade(api, device, coordinator) + if device.device_type == "Garage Door Opener": + return SwitchBotCloudCoverGarageDoorOpener(api, device, coordinator) + return SwitchBotCloudCoverCurtain(api, device, coordinator) diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py new file mode 100644 index 00000000000..9424b5478ac --- /dev/null +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -0,0 +1,203 @@ +"""Support for the Switchbot Battery Circulator fan.""" + +import asyncio +import logging +from typing import Any + +from switchbot_api import ( + AirPurifierCommands, + BatteryCirculatorFanCommands, + BatteryCirculatorFanMode, + CommonCommands, + SwitchBotAPI, +) + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import AFTER_COMMAND_REFRESH, DOMAIN, AirPurifierMode +from .entity import SwitchBotCloudEntity + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + for device, coordinator in data.devices.fans: + if device.device_type.startswith("Air Purifier"): + async_add_entities( + [SwitchBotAirPurifierEntity(data.api, device, coordinator)] + ) + else: + async_add_entities([SwitchBotCloudFan(data.api, device, coordinator)]) + + +class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): + """Representation of a SwitchBot Battery Circulator Fan.""" + + _attr_name = None + + _api: SwitchBotAPI + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = list(BatteryCirculatorFanMode) + + _attr_is_on: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if the entity is on.""" + return self._attr_is_on + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + power: str = self.coordinator.data["power"] + mode: str = self.coordinator.data["mode"] + fan_speed: str = self.coordinator.data["fanSpeed"] + self._attr_is_on = power == "on" + self._attr_preset_mode = mode + self._attr_percentage = int(fan_speed) + self._attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + await self.send_api_command(CommonCommands.ON) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(self.preset_mode), + ) + if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(self.percentage), + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + 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), + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=preset_mode, + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +class SwitchBotAirPurifierEntity(SwitchBotCloudEntity, FanEntity): + """Representation of a Switchbot air purifier.""" + + _api: SwitchBotAPI + _attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = AirPurifierMode.get_modes() + _attr_translation_key = "air_purifier" + _attr_name = None + _attr_is_on: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self._attr_is_on + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + self._attr_is_on = self.coordinator.data.get("power") == STATE_ON.upper() + mode = self.coordinator.data.get("mode") + self._attr_preset_mode = ( + AirPurifierMode(mode).name.lower() if mode is not None else None + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the air purifier.""" + + _LOGGER.debug( + "Switchbot air purifier to set preset mode %s %s", + preset_mode, + self._attr_unique_id, + ) + await self.send_api_command( + AirPurifierCommands.SET_MODE, + parameters={"mode": AirPurifierMode[preset_mode.upper()].value}, + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the air purifier.""" + + _LOGGER.debug( + "Switchbot air purifier to set turn on %s %s %s", + percentage, + preset_mode, + self._attr_unique_id, + ) + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the air purifier.""" + + _LOGGER.debug("Switchbot air purifier to set turn off %s", self._attr_unique_id) + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json new file mode 100644 index 00000000000..2a13cbe7579 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -0,0 +1,22 @@ +{ + "entity": { + "fan": { + "air_purifier": { + "default": "mdi:air-purifier", + "state": { + "off": "mdi:air-purifier-off" + }, + "state_attributes": { + "preset_mode": { + "state": { + "normal": "mdi:fan", + "auto": "mdi:auto-mode", + "pet": "mdi:paw", + "sleep": "mdi:power-sleep" + } + } + } + } + } + } +} diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py new file mode 100644 index 00000000000..645c6b4c62b --- /dev/null +++ b/homeassistant/components/switchbot_cloud/light.py @@ -0,0 +1,153 @@ +"""Support for the Switchbot Light.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + CommonCommands, + Device, + Remote, + RGBWLightCommands, + RGBWWLightCommands, + SwitchBotAPI, +) + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData, SwitchBotCoordinator +from .const import AFTER_COMMAND_REFRESH, DOMAIN +from .entity import SwitchBotCloudEntity + + +def value_map_brightness(value: int) -> int: + """Return value for brightness map.""" + return int(value / 255 * 100) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.lights + ) + + +class SwitchBotCloudLight(SwitchBotCloudEntity, LightEntity): + """Base Class for SwitchBot Light.""" + + _attr_is_on: bool | None = None + _attr_name: str | None = None + + _attr_color_mode = ColorMode.UNKNOWN + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + power: str | None = self.coordinator.data.get("power") + brightness: int | None = self.coordinator.data.get("brightness") + color: str | None = self.coordinator.data.get("color") + color_temperature: int | None = self.coordinator.data.get("colorTemperature") + self._attr_is_on = power == "on" if power else None + self._attr_brightness: int | None = brightness if brightness else None + self._attr_rgb_color: tuple | None = ( + (tuple(int(i) for i in color.split(":"))) if color else None + ) + self._attr_color_temp_kelvin: int | None = ( + color_temperature if color_temperature else None + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + brightness: int | None = kwargs.get("brightness") + rgb_color: tuple[int, int, int] | None = kwargs.get("rgb_color") + color_temp_kelvin: int | None = kwargs.get("color_temp_kelvin") + if brightness is not None: + self._attr_color_mode = ColorMode.RGB + await self._send_brightness_command(brightness) + elif rgb_color is not None: + self._attr_color_mode = ColorMode.RGB + await self._send_rgb_color_command(rgb_color) + elif color_temp_kelvin is not None: + self._attr_color_mode = ColorMode.COLOR_TEMP + await self._send_color_temperature_command(color_temp_kelvin) + else: + self._attr_color_mode = ColorMode.RGB + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def _send_brightness_command(self, brightness: int) -> None: + """Send a brightness command.""" + await self.send_api_command( + RGBWLightCommands.SET_BRIGHTNESS, + parameters=str(value_map_brightness(brightness)), + ) + + async def _send_rgb_color_command(self, rgb_color: tuple) -> None: + """Send an RGB command.""" + await self.send_api_command( + RGBWLightCommands.SET_COLOR, + parameters=f"{rgb_color[2]}:{rgb_color[1]}:{rgb_color[0]}", + ) + + async def _send_color_temperature_command(self, color_temp_kelvin: int) -> None: + """Send a color temperature command.""" + await self.send_api_command( + RGBWWLightCommands.SET_COLOR_TEMPERATURE, + parameters=str(color_temp_kelvin), + ) + + +class SwitchBotCloudStripLight(SwitchBotCloudLight): + """Representation of a SwitchBot Strip Light.""" + + _attr_supported_color_modes = {ColorMode.RGB} + + +class SwitchBotCloudRGBWWLight(SwitchBotCloudLight): + """Representation of SwitchBot |Strip Light|Floor Lamp|Color Bulb.""" + + _attr_max_color_temp_kelvin = 6500 + _attr_min_color_temp_kelvin = 2700 + + _attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP} + + async def _send_brightness_command(self, brightness: int) -> None: + """Send a brightness command.""" + await self.send_api_command( + RGBWWLightCommands.SET_BRIGHTNESS, + parameters=str(value_map_brightness(brightness)), + ) + + async def _send_rgb_color_command(self, rgb_color: tuple) -> None: + """Send an RGB command.""" + await self.send_api_command( + RGBWWLightCommands.SET_COLOR, + parameters=f"{rgb_color[0]}:{rgb_color[1]}:{rgb_color[2]}", + ) + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> SwitchBotCloudStripLight | SwitchBotCloudRGBWWLight: + """Make a SwitchBotCloudLight.""" + if device.device_type == "Strip Light": + return SwitchBotCloudStripLight(api, device, coordinator) + return SwitchBotCloudRGBWWLight(api, device, coordinator) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 076fa8dd6fb..b07bae88072 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot_api"], - "requirements": ["switchbot-api==2.5.0"] + "requirements": ["switchbot-api==2.7.0"] } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 9920717a8d7..163b1653686 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -91,6 +91,7 @@ CO2_DESCRIPTION = SensorEntityDescription( SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), + "Battery Circulator Fan": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, @@ -113,11 +114,11 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { ), "Plug Mini (US)": ( VOLTAGE_DESCRIPTION, - CURRENT_DESCRIPTION_IN_A, + CURRENT_DESCRIPTION_IN_MA, ), "Plug Mini (JP)": ( VOLTAGE_DESCRIPTION, - CURRENT_DESCRIPTION_IN_A, + CURRENT_DESCRIPTION_IN_MA, ), "Hub 2": ( TEMPERATURE_DESCRIPTION, @@ -134,8 +135,14 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { BATTERY_DESCRIPTION, CO2_DESCRIPTION, ), - "Smart Lock Pro": (BATTERY_DESCRIPTION,), "Smart Lock": (BATTERY_DESCRIPTION,), + "Smart Lock Lite": (BATTERY_DESCRIPTION,), + "Smart Lock Pro": (BATTERY_DESCRIPTION,), + "Smart Lock Ultra": (BATTERY_DESCRIPTION,), + "Curtain": (BATTERY_DESCRIPTION,), + "Curtain3": (BATTERY_DESCRIPTION,), + "Roller Shade": (BATTERY_DESCRIPTION,), + "Blind Tilt": (BATTERY_DESCRIPTION,), } @@ -151,7 +158,6 @@ async def async_setup_entry( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] - if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index 11e92e6dfa3..adb7de00682 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -16,5 +16,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "fan": { + "air_purifier": { + "state_attributes": { + "preset_mode": { + "state": { + "normal": "[%key:common::state::normal%]", + "auto": "[%key:common::state::auto%]", + "pet": "Pet", + "sleep": "Sleep" + } + } + } + } + } } } diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 9a9ad49626f..7bc4c7d0ea2 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -2,7 +2,15 @@ from typing import Any -from switchbot_api import Device, Remote, SwitchBotAPI, VacuumCommands +from switchbot_api import ( + Device, + Remote, + SwitchBotAPI, + VacuumCleanerV2Commands, + VacuumCleanerV3Commands, + VacuumCleanMode, + VacuumCommands, +) from homeassistant.components.vacuum import ( StateVacuumEntity, @@ -63,6 +71,11 @@ VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: dict[str, str] = { class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): """Representation of a SwitchBot vacuum.""" + # "K10+" + # "K10+ Pro" + # "Robot Vacuum Cleaner S1" + # "Robot Vacuum Cleaner S1 Plus" + _attr_supported_features: VacuumEntityFeature = ( VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED @@ -85,23 +98,26 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): VacuumCommands.POW_LEVEL, parameters=VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed], ) - self.async_write_ha_state() + await self.coordinator.async_request_refresh() async def async_pause(self) -> None: """Pause the cleaning task.""" await self.send_api_command(VacuumCommands.STOP) + self.async_write_ha_state() async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" await self.send_api_command(VacuumCommands.DOCK) + await self.coordinator.async_request_refresh() async def async_start(self) -> None: """Start or resume the cleaning task.""" await self.send_api_command(VacuumCommands.START) + await self.coordinator.async_request_refresh() def _set_attributes(self) -> None: """Set attributes from coordinator data.""" - if not self.coordinator.data: + if self.coordinator.data is None: return self._attr_battery_level = self.coordinator.data.get("battery") @@ -109,11 +125,127 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): switchbot_state = str(self.coordinator.data.get("workingStatus")) self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) + if self._attr_fan_speed is None: + self._attr_fan_speed = VACUUM_FAN_SPEED_QUIET + + +class SwitchBotCloudVacuumK20PlusPro(SwitchBotCloudVacuum): + """Representation of a SwitchBot K20+ Pro.""" + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._attr_fan_speed = fan_speed + await self.send_api_command( + VacuumCleanerV2Commands.CHANGE_PARAM, + parameters={ + "fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + 1, + "waterLevel": 1, + "times": 1, + }, + ) + await self.coordinator.async_request_refresh() + + async def async_pause(self) -> None: + """Pause the cleaning task.""" + await self.send_api_command(VacuumCleanerV2Commands.PAUSE) + await self.coordinator.async_request_refresh() + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + await self.send_api_command(VacuumCleanerV2Commands.DOCK) + await self.coordinator.async_request_refresh() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + fan_level = ( + VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED.get(self.fan_speed) + if self.fan_speed + else None + ) + await self.send_api_command( + VacuumCleanerV2Commands.START_CLEAN, + parameters={ + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": int(fan_level if fan_level else VACUUM_FAN_SPEED_QUIET) + + 1, + "times": 1, + }, + }, + ) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudVacuumK10PlusProCombo(SwitchBotCloudVacuumK20PlusPro): + """Representation of a SwitchBot vacuum K10+ Pro Combo.""" + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._attr_fan_speed = fan_speed + if fan_speed in VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: + await self.send_api_command( + VacuumCleanerV2Commands.CHANGE_PARAM, + parameters={ + "fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + + 1, + "times": 1, + }, + ) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudVacuumV3(SwitchBotCloudVacuumK20PlusPro): + """Representation of a SwitchBot vacuum Robot Vacuum Cleaner S10 & S20.""" + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._attr_fan_speed = fan_speed + await self.send_api_command( + VacuumCleanerV3Commands.CHANGE_PARAM, + parameters={ + "fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + 1, + "waterLevel": 1, + "times": 1, + }, + ) + await self.coordinator.async_request_refresh() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + fan_level = ( + VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED.get(self.fan_speed) + if self.fan_speed + else None + ) + await self.send_api_command( + VacuumCleanerV3Commands.START_CLEAN, + parameters={ + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": int(fan_level if fan_level else VACUUM_FAN_SPEED_QUIET), + "waterLevel": 1, + "times": 1, + }, + }, + ) + await self.coordinator.async_request_refresh() @callback def _async_make_entity( api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator -) -> SwitchBotCloudVacuum: +) -> ( + SwitchBotCloudVacuum + | SwitchBotCloudVacuumK20PlusPro + | SwitchBotCloudVacuumV3 + | SwitchBotCloudVacuumK10PlusProCombo +): """Make a SwitchBotCloudVacuum.""" + if device.device_type in VacuumCleanerV2Commands.get_supported_devices(): + if device.device_type == "K20+ Pro": + return SwitchBotCloudVacuumK20PlusPro(api, device, coordinator) + return SwitchBotCloudVacuumK10PlusProCombo(api, device, coordinator) + + if device.device_type in VacuumCleanerV3Commands.get_supported_devices(): + return SwitchBotCloudVacuumV3(api, device, coordinator) return SwitchBotCloudVacuum(api, device, coordinator) diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py index 0b96b354436..27239a5a520 100644 --- a/homeassistant/components/syncthru/coordinator.py +++ b/homeassistant/components/syncthru/coordinator.py @@ -28,6 +28,7 @@ class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): hass, _LOGGER, name=DOMAIN, + config_entry=entry, update_interval=timedelta(seconds=30), ) self.syncthru = SyncThru( diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index e568ce5a6d1..7146d42136e 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -136,7 +136,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) coordinator_switches=coordinator_switches, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) if entry.options[CONF_BACKUP_SHARE]: @@ -172,13 +171,6 @@ async def async_unload_entry( return unload_ok -async def _async_update_listener( - hass: HomeAssistant, entry: SynologyDSMConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index f0da6f8fe47..6e3469970d1 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DISKS, @@ -441,7 +441,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return None -class SynologyDSMOptionsFlowHandler(OptionsFlow): +class SynologyDSMOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" config_entry: SynologyDSMConfigEntry diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 2799cf31fdd..c19f36f14dd 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==4.1.5", "systembridgemodels==4.2.4"], + "requirements": ["systembridgeconnector==4.1.10"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 7ab6d77e137..37e9ee3d929 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -231,7 +231,7 @@ async def handle_info( "Error fetching system info for %s - %s", domain, key, - exc_info=(type(exception), exception, exception.__traceback__), # noqa: LOG014 + exc_info=(type(exception), exception, exception.__traceback__), ) event_msg["success"] = False event_msg["error"] = {"type": "failed", "error": "unknown"} diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index b2b60db9675..2a85b1f31e1 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -23,8 +23,6 @@ async def async_setup_entry( entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -35,10 +33,3 @@ async def async_unload_entry( ) -> bool: """Unload Tankerkoenig config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener( - hass: HomeAssistant, entry: TankerkoenigConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index a38266e57e8..d571dfe99d2 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -17,6 +17,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index b269eaaaf55..6207c7261b0 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -15,10 +15,9 @@ from aiotankerkoenig import ( import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -40,6 +39,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN +from .coordinator import TankerkoenigConfigEntry async def async_get_nearby_stations( @@ -71,7 +71,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: TankerkoenigConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -229,7 +229,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" def __init__(self) -> None: diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index f1e6bc8c865..dbd826b9359 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -131,19 +131,31 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf stations, err, ) - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except TankerkoenigRateLimitError as err: _LOGGER.warning( "API rate limit reached, consider to increase polling interval" ) - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="rate_limit_reached", + ) from err except (TankerkoenigError, TankerkoenigConnectionError) as err: _LOGGER.debug( "error occur during update of stations %s %s", stations, err, ) - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="station_update_failed", + translation_placeholders={ + "station_ids": ", ".join(stations), + }, + ) from err prices.update(data) diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 72248d006e0..eeb8646bea7 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], + "quality_scale": "platinum", "requirements": ["aiotankerkoenig==0.4.2"] } diff --git a/homeassistant/components/tankerkoenig/quality_scale.yaml b/homeassistant/components/tankerkoenig/quality_scale.yaml new file mode 100644 index 00000000000..5def972b636 --- /dev/null +++ b/homeassistant/components/tankerkoenig/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom actions provided. + 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 provided. + 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 provided. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: It's a pure webservice, without real devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Each config entry represents one service entry. + 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: | + All possible changes are already covered by re-auth and options flow. + repair-issues: + status: exempt + comment: No repair issues implemented. + stale-devices: + status: exempt + comment: Each config entry represents one service entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index b1646489d96..9964a300d6f 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -24,6 +24,9 @@ from .const import ( from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) @@ -107,7 +110,14 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): self._attr_extra_state_attributes = attrs @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the current price for the fuel type.""" info = self.coordinator.data[self._station_id] - return getattr(info, self._fuel_type) + result = None + if self._fuel_type is GasType.E10: + result = info.e10 + elif self._fuel_type is GasType.E5: + result = info.e5 + else: + result = info.diesel + return result diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index db620b2b11c..43922a930af 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -1,4 +1,11 @@ { + "common": { + "data_description_api_key": "The tankerkoenig API key to be used.", + "data_description_location": "Pick the location where to search for gas stations.", + "data_description_name": "The name of the particular region to be added.", + "data_description_radius": "The radius in kilometers to search for gas stations around the selected location.", + "data_description_stations": "Select the stations you want to add to Home Assistant." + }, "config": { "step": { "user": { @@ -6,13 +13,21 @@ "name": "Region name", "api_key": "[%key:common::config_flow::data::api_key%]", "location": "[%key:common::config_flow::data::location%]", - "stations": "Additional fuel stations", "radius": "Search radius" + }, + "data_description": { + "name": "[%key:component::tankerkoenig::common::data_description_name%]", + "api_key": "[%key:component::tankerkoenig::common::data_description_api_key%]", + "location": "[%key:component::tankerkoenig::common::data_description_location%]", + "radius": "[%key:component::tankerkoenig::common::data_description_radius%]" } }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::tankerkoenig::common::data_description_api_key%]" } }, "select_station": { @@ -20,6 +35,9 @@ "description": "Found {stations_count} stations in radius", "data": { "stations": "Stations" + }, + "data_description": { + "stations": "[%key:component::tankerkoenig::common::data_description_stations%]" } } }, @@ -39,6 +57,10 @@ "data": { "stations": "[%key:component::tankerkoenig::config::step::select_station::data::stations%]", "show_on_map": "Show stations on map" + }, + "data_description": { + "stations": "[%key:component::tankerkoenig::common::data_description_stations%]", + "show_on_map": "Whether to show the station sensors on the map or not." } } }, @@ -158,5 +180,16 @@ } } } + }, + "exceptions": { + "rate_limit_reached": { + "message": "You have reached the rate limit for the Tankerkoenig API. Please try to increase the poll interval and reduce the requests." + }, + "invalid_api_key": { + "message": "The provided API key is invalid. Please check your API key." + }, + "station_update_failed": { + "message": "Failed to update station data for station(s) {station_ids}. Please check your network connection." + } } } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 5bdc670d69c..50c721e5f37 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_SOURCE, CONF_URL, + Platform, ) from homeassistant.core import ( HomeAssistant, @@ -29,6 +30,7 @@ from homeassistant.core import ( from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, + HomeAssistantError, ServiceValidationError, ) from homeassistant.helpers import config_validation as cv @@ -290,6 +292,8 @@ MODULES: dict[str, ModuleType] = { PLATFORM_WEBHOOKS: webhooks, } +PLATFORMS: list[Platform] = [Platform.NOTIFY] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Telegram bot component.""" @@ -390,9 +394,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: elif msgtype == SERVICE_DELETE_MESSAGE: await notify_service.delete_message(context=service.context, **kwargs) elif msgtype == SERVICE_LEAVE_CHAT: - messages = await notify_service.leave_chat( - context=service.context, **kwargs - ) + await notify_service.leave_chat(context=service.context, **kwargs) elif msgtype == SERVICE_SET_MESSAGE_REACTION: await notify_service.set_message_reaction(context=service.context, **kwargs) else: @@ -400,12 +402,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: msgtype, context=service.context, **kwargs ) - if service.return_response and messages: + if service.return_response and messages is not None: + target: list[int] | None = service.data.get(ATTR_TARGET) + if not target: + target = notify_service.get_target_chat_ids(None) + + failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages] + if failed_chat_ids: + raise HomeAssistantError( + f"Failed targets: {failed_chat_ids}", + translation_domain=DOMAIN, + translation_key="failed_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join([str(i) for i in failed_chat_ids]), + "bot_name": config_entry.title, + }, + ) + return { "chats": [ {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() ] } + return None # Register notification services @@ -461,15 +480,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) ) entry.runtime_data = notify_service + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True async def update_listener(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> None: - """Handle options update.""" + """Handle config changes.""" entry.runtime_data.parse_mode = entry.options[ATTR_PARSER] + # reload entities + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async def async_unload_entry( hass: HomeAssistant, entry: TelegramBotConfigEntry @@ -478,4 +503,5 @@ async def async_unload_entry( # broadcast platform has no app if entry.runtime_data.app: await entry.runtime_data.app.shutdown() - return True + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 4a00aff8d3f..3145badbed7 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -101,13 +101,26 @@ _LOGGER = logging.getLogger(__name__) type TelegramBotConfigEntry = ConfigEntry[TelegramNotificationService] +def _get_bot_info(bot: Bot, config_entry: ConfigEntry) -> dict[str, Any]: + return { + "config_entry_id": config_entry.entry_id, + "id": bot.id, + "first_name": bot.first_name, + "last_name": bot.last_name, + "username": bot.username, + } + + class BaseTelegramBot: """The base class for the telegram bot.""" - def __init__(self, hass: HomeAssistant, config: TelegramBotConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config: TelegramBotConfigEntry, bot: Bot + ) -> None: """Initialize the bot base class.""" self.hass = hass self.config = config + self._bot = bot @abstractmethod async def shutdown(self) -> None: @@ -134,6 +147,8 @@ class BaseTelegramBot: _LOGGER.warning("Unhandled update: %s", update) return True + event_data["bot"] = _get_bot_info(self._bot, self.config) + event_context = Context() _LOGGER.debug("Firing event %s: %s", event_type, event_data) @@ -287,24 +302,32 @@ class TelegramNotificationService: inline_message_id = msg_data["inline_message_id"] return message_id, inline_message_id - def _get_target_chat_ids(self, target: Any) -> list[int]: + 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] = self._get_allowed_chat_ids() - default_user: int = allowed_chat_ids[0] - if target is not None: - if isinstance(target, int): - target = [target] - chat_ids = [t for t in target if t in allowed_chat_ids] - if chat_ids: - return chat_ids - _LOGGER.warning( - "Disallowed targets: %s, using default: %s", target, default_user + + 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 [default_user] + return valid_chat_ids def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" @@ -366,9 +389,7 @@ class TelegramNotificationService: } if data is not None: if ATTR_PARSER in data: - params[ATTR_PARSER] = self._parsers.get( - data[ATTR_PARSER], self.parse_mode - ) + params[ATTR_PARSER] = data[ATTR_PARSER] if ATTR_TIMEOUT in data: params[ATTR_TIMEOUT] = data[ATTR_TIMEOUT] if ATTR_DISABLE_NOTIF in data: @@ -400,6 +421,8 @@ class TelegramNotificationService: params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup( [_make_row_inline_keyboard(row) for row in keys] ) + if params[ATTR_PARSER] == PARSER_PLAIN_TEXT: + params[ATTR_PARSER] = None return params async def _send_msg( @@ -414,9 +437,9 @@ class TelegramNotificationService: """Send one message.""" try: out = await func_send(*args_msg, **kwargs_msg) - if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID): + if isinstance(out, Message): chat_id = out.chat_id - message_id = out[ATTR_MESSAGEID] + message_id = out.message_id self._last_message_id[chat_id] = message_id _LOGGER.debug( "Last message ID: %s (from chat_id %s)", @@ -424,7 +447,7 @@ class TelegramNotificationService: chat_id, ) - event_data = { + event_data: dict[str, Any] = { ATTR_CHAT_ID: chat_id, ATTR_MESSAGEID: message_id, } @@ -434,13 +457,12 @@ class TelegramNotificationService: 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 ) - elif not isinstance(out, bool): - _LOGGER.warning( - "Update last message: out_type:%s, out=%s", type(out), out - ) except TelegramError as exc: _LOGGER.error( "%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg @@ -460,7 +482,7 @@ class TelegramNotificationService: text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) msg = await self._send_msg( self.bot.send_message, @@ -488,7 +510,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> bool: """Delete a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + 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( @@ -513,7 +535,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> Any: """Edit a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + 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( @@ -620,7 +642,7 @@ class TelegramNotificationService: msg_ids = {} if file_content: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: @@ -738,7 +760,7 @@ class TelegramNotificationService: msg_ids = {} if stickerid: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", @@ -769,7 +791,7 @@ class TelegramNotificationService: longitude = float(longitude) params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) @@ -803,7 +825,7 @@ class TelegramNotificationService: params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) msg = await self._send_msg( self.bot.send_poll, @@ -826,12 +848,12 @@ class TelegramNotificationService: async def leave_chat( self, - chat_id: Any = None, + chat_id: int | None = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Remove bot from chat.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + 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 @@ -839,14 +861,14 @@ class TelegramNotificationService: async def set_message_reaction( self, - chat_id: int, reaction: str, + chat_id: int | None = None, 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] + 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) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index d9b334a4ac1..c71d8a1ad1e 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -7,7 +7,7 @@ from types import MappingProxyType from typing import Any from telegram import Bot, ChatFullInfo -from telegram.error import BadRequest, InvalidToken, NetworkError +from telegram.error import BadRequest, InvalidToken, TelegramError import voluptuous as vol from homeassistant.config_entries import ( @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, section from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.network import NoURLAvailableError, get_url @@ -58,6 +58,7 @@ from .const import ( PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) @@ -81,8 +82,15 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema( autocomplete="current-password", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -98,8 +106,15 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema( translation_key="platforms", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -144,8 +159,6 @@ class OptionsFlowHandler(OptionsFlow): """Manage the options.""" if user_input is not None: - if user_input[ATTR_PARSER] == PARSER_PLAIN_TEXT: - user_input[ATTR_PARSER] = None return self.async_create_entry(data=user_input) return self.async_show_form( @@ -197,6 +210,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): import_data[CONF_TRUSTED_NETWORKS] = ",".join( import_data[CONF_TRUSTED_NETWORKS] ) + import_data[SECTION_ADVANCED_SETTINGS] = { + CONF_PROXY_URL: import_data.get(CONF_PROXY_URL) + } try: config_flow_result: ConfigFlowResult = await self.async_step_user( import_data @@ -219,12 +235,13 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): subentries: list[ConfigSubentryData] = [] allowed_chat_ids: list[int] = import_data[CONF_ALLOWED_CHAT_IDS] + assert self._bot is not None, "Bot should be initialized during import" for chat_id in allowed_chat_ids: chat_name: str = await _async_get_chat_name(self._bot, chat_id) subentry: ConfigSubentryData = ConfigSubentryData( data={CONF_CHAT_ID: chat_id}, subentry_type=CONF_ALLOWED_CHAT_IDS, - title=chat_name, + title=f"{chat_name} ({chat_id})", unique_id=str(chat_id), ) subentries.append(subentry) @@ -293,10 +310,15 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow to create a new config entry for a Telegram bot.""" + description_placeholders: dict[str, str] = { + "botfather_username": "@BotFather", + "botfather_url": "https://t.me/botfather", + } if not user_input: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders=description_placeholders, ) # prevent duplicates @@ -305,7 +327,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # validate connection to Telegram API errors: dict[str, str] = {} - description_placeholders: dict[str, str] = {} + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) bot_name = await self._validate_bot( user_input, errors, description_placeholders ) @@ -328,7 +352,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), }, options={ # this value may come from yaml import @@ -353,7 +379,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Shutdown the bot if it exists.""" if self._bot: await self._bot.shutdown() - self._bot = None async def _validate_bot( self, @@ -374,13 +399,17 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): placeholders[ERROR_FIELD] = "API key" placeholders[ERROR_MESSAGE] = str(err) return "Unknown bot" - except (ValueError, NetworkError) as err: + except ValueError as err: _LOGGER.warning("Invalid proxy") errors["base"] = "invalid_proxy_url" placeholders["proxy_url_error"] = str(err) placeholders[ERROR_FIELD] = "proxy url" placeholders[ERROR_MESSAGE] = str(err) return "Unknown bot" + except TelegramError as err: + errors["base"] = "telegram_error" + placeholders[ERROR_MESSAGE] = str(err) + return "Unknown bot" else: return user.full_name @@ -390,12 +419,20 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle config flow for webhook Telegram bot.""" if not user_input: + default_trusted_networks = ",".join( + [str(network) for network in DEFAULT_TRUSTED_NETWORKS] + ) + if self.source == SOURCE_RECONFIGURE: + suggested_values = dict(self._get_reconfigure_entry().data) + if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data: + suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks + return self.async_show_form( step_id="webhooks", data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, - self._get_reconfigure_entry().data, + suggested_values, ), ) @@ -404,9 +441,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, { - CONF_TRUSTED_NETWORKS: ",".join( - [str(network) for network in DEFAULT_TRUSTED_NETWORKS] - ), + CONF_TRUSTED_NETWORKS: default_trusted_networks, }, ), ) @@ -440,7 +475,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: self._step_user_data[CONF_PLATFORM], CONF_API_KEY: self._step_user_data[CONF_API_KEY], - CONF_PROXY_URL: self._step_user_data.get(CONF_PROXY_URL), + CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), CONF_URL: user_input.get(CONF_URL), CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS], }, @@ -455,12 +492,8 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders: dict[str, str], ) -> None: # validate URL - if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"): - errors["base"] = "invalid_url" - description_placeholders[ERROR_FIELD] = "URL" - description_placeholders[ERROR_MESSAGE] = "URL must start with https" - return - if CONF_URL not in user_input: + url: str | None = user_input.get(CONF_URL) + if url is None: try: get_url(self.hass, require_ssl=True, allow_internal=False) except NoURLAvailableError: @@ -470,6 +503,11 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): "URL is required since you have not configured an external URL in Home Assistant" ) return + elif not url.startswith("https"): + errors["base"] = "invalid_url" + description_placeholders[ERROR_FIELD] = "URL" + description_placeholders[ERROR_MESSAGE] = "URL must start with https" + return # validate trusted networks csv_trusted_networks: list[str] = [] @@ -505,9 +543,19 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - self._get_reconfigure_entry().data, + { + **self._get_reconfigure_entry().data, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: self._get_reconfigure_entry().data.get( + CONF_PROXY_URL + ), + }, + }, ), ) + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} @@ -523,7 +571,12 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - user_input, + { + **user_input, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + }, + }, ), errors=errors, description_placeholders=description_placeholders, @@ -598,7 +651,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): chat_name = await _async_get_chat_name(bot, chat_id) if chat_name: return self.async_create_entry( - title=chat_name, + title=f"{chat_name} ({chat_id})", data={CONF_CHAT_ID: chat_id}, unique_id=str(chat_id), ) @@ -612,10 +665,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): ) -async def _async_get_chat_name(bot: Bot | None, chat_id: int) -> str: - if not bot: - return str(chat_id) - +async def _async_get_chat_name(bot: Bot, chat_id: int) -> str: try: chat_info: ChatFullInfo = await bot.get_chat(chat_id) return chat_info.effective_name or str(chat_id) diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index d6da96d9a28..0f1d5193e2c 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -7,7 +7,7 @@ DOMAIN = "telegram_bot" PLATFORM_BROADCAST = "broadcast" PLATFORM_POLLING = "polling" PLATFORM_WEBHOOKS = "webhooks" - +SECTION_ADVANCED_SETTINGS = "advanced_settings" SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_BOT_COUNT = "bot_count" diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 27c10602350..7a01f43c528 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -1,7 +1,7 @@ { "domain": "telegram_bot", "name": "Telegram bot", - "codeowners": [], + "codeowners": ["@hanwg"], "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/telegram_bot", diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py new file mode 100644 index 00000000000..822bd7b925d --- /dev/null +++ b/homeassistant/components/telegram_bot/notify.py @@ -0,0 +1,62 @@ +"""Telegram bot notification entity.""" + +from typing import Any + +import telegram + +from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TelegramBotConfigEntry +from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TelegramBotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the telegram bot notification entity platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [TelegramBotNotifyEntity(config_entry, subentry)], + config_subentry_id=subentry_id, + ) + + +class TelegramBotNotifyEntity(NotifyEntity): + """Representation of a telegram bot notification entity.""" + + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + config_entry: TelegramBotConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + bot_id = config_entry.runtime_data.bot.id + chat_id = subentry.data[CONF_CHAT_ID] + + self._attr_unique_id = f"{bot_id}_{chat_id}" + self.name = subentry.title + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="Telegram", + model=config_entry.data[CONF_PLATFORM].capitalize(), + sw_version=telegram.__version__, + identifiers={(DOMAIN, f"{bot_id}")}, + ) + self._target = chat_id + self._service = config_entry.runtime_data + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + kwargs: dict[str, Any] = {ATTR_TITLE: title} + await self._service.send_message(message, self._target, self._context, **kwargs) diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 6c38a0e53b8..b8640c5c005 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -54,7 +54,7 @@ class PollBot(BaseTelegramBot): self, hass: HomeAssistant, bot: Bot, config: TelegramBotConfigEntry ) -> None: """Create Application to poll for updates.""" - super().__init__(hass, config) + super().__init__(hass, config, bot) self.bot = bot self.application = ApplicationBuilder().bot(self.bot).build() self.application.add_handler(TypeHandler(Update, self.handle_update)) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index d5fc0e134d5..0ebe7988642 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -18,7 +18,8 @@ send_message: target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -43,7 +44,8 @@ send_message: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or ["Text button1:/button1, Text @@ -82,6 +84,14 @@ send_photo: example: "My image" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -90,17 +100,12 @@ send_photo: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -109,6 +114,7 @@ send_photo: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -124,7 +130,8 @@ send_photo: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -162,6 +169,14 @@ send_sticker: example: CAACAgIAAxkBAAEDDldhZD-hqWclr6krLq-FWSfCrGNmOQAC9gAD9HsZAAFeYY-ltPYnrCEE selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -170,17 +185,12 @@ send_sticker: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true disable_notification: selector: boolean: @@ -196,7 +206,8 @@ send_sticker: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -234,6 +245,14 @@ send_animation: example: "My animation" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -242,17 +261,12 @@ send_animation: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -261,6 +275,7 @@ send_animation: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -276,7 +291,8 @@ send_animation: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -314,6 +330,14 @@ send_video: example: "My video" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -322,17 +346,12 @@ send_video: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -341,6 +360,7 @@ send_video: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -356,7 +376,8 @@ send_video: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -394,6 +415,14 @@ send_voice: example: "My microphone recording" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -402,17 +431,12 @@ send_voice: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true disable_notification: selector: boolean: @@ -428,7 +452,8 @@ send_voice: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -466,6 +491,14 @@ send_document: example: Document Title xy selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -474,17 +507,12 @@ send_document: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -493,6 +521,7 @@ send_document: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -508,7 +537,8 @@ send_document: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -553,7 +583,8 @@ send_location: target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true disable_notification: selector: boolean: @@ -566,7 +597,8 @@ send_location: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -595,7 +627,8 @@ send_poll: target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true question: required: true selector: @@ -670,6 +703,7 @@ edit_message: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_web_page_preview: selector: boolean: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index e932d010894..29bf51ecd0c 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -2,17 +2,25 @@ "config": { "step": { "user": { - "title": "Telegram bot setup", - "description": "Create a new Telegram bot", + "description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.", "data": { "platform": "Platform", - "api_key": "[%key:common::config_flow::data::api_key%]", - "proxy_url": "Proxy URL" + "api_key": "[%key:common::config_flow::data::api_token%]" }, "data_description": { "platform": "Telegram bot implementation", - "api_key": "The API token of your bot.", - "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + "api_key": "The API token of your bot." + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "proxy_url": "Proxy URL" + }, + "data_description": { + "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + } + } } }, "webhooks": { @@ -30,12 +38,21 @@ "title": "Telegram bot setup", "description": "Reconfigure Telegram bot", "data": { - "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]" }, "data_description": { - "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data_description::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]" + }, + "sections": { + "advanced_settings": { + "name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]", + "data": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]" + }, + "data_description": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]" + } + } } }, "reauth_confirm": { @@ -49,6 +66,7 @@ } }, "error": { + "telegram_error": "Error from Telegram: {error_message}", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "invalid_proxy_url": "{proxy_url_error}", "no_url_available": "URL is required since you have not configured an external URL in Home Assistant", @@ -113,6 +131,13 @@ "html": "HTML", "plain_text": "Plain text" } + }, + "authentication": { + "options": { + "basic": "Basic", + "digest": "Digest", + "bearer_token": "Bearer token" + } } }, "services": { @@ -196,15 +221,15 @@ }, "username": { "name": "[%key:common::config_flow::data::username%]", - "description": "Username for a URL which require HTTP authentication." + "description": "Username for a URL that requires 'Basic' or 'Digest' authentication." }, "password": { "name": "[%key:common::config_flow::data::password%]", - "description": "Password (or bearer token) for a URL which require HTTP authentication." + "description": "Password (or bearer token) for a URL that requires authentication." }, "authentication": { "name": "Authentication method", - "description": "Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`." + "description": "Define which authentication method to use. Set to 'Basic' for HTTP basic authentication, 'Digest' for HTTP digest authentication, or 'Bearer token' for OAuth 2.0 bearer token authentication." }, "target": { "name": "Target", @@ -895,6 +920,12 @@ "missing_allowed_chat_ids": { "message": "No allowed chat IDs found. Please add allowed chat IDs for {bot_name}." }, + "invalid_chat_ids": { + "message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}." + }, + "failed_chat_ids": { + "message": "Failed targets: {chat_ids}. Please verify that the chat IDs for {bot_name} have been configured." + }, "missing_input": { "message": "{field} is required." }, @@ -920,10 +951,6 @@ "deprecated_yaml_import_issue_error": { "title": "YAML import failed due to invalid {error_field}", "description": "Configuring {integration_title} using YAML is being removed but there was an error while importing your existing configuration ({telegram_bot}): {error_message}.\nSetup will not proceed.\n\nVerify that your {telegram_bot} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "proxy_params_auth_deprecation": { - "title": "{telegram_bot}: Proxy authentication should be moved to the URL", - "description": "Authentication details for the the proxy configured in the {telegram_bot} integration should be moved into the {proxy_url} instead. Please update your configuration and restart Home Assistant to fix this issue.\n\nThe {proxy_params} config key will be removed in a future release." } } } diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 0bfad34681a..61843e6ffbf 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -77,12 +77,12 @@ class PushBot(BaseTelegramBot): # Dumb Application that just gets our updates to our handler callback (self.handle_update) self.application = ApplicationBuilder().bot(bot).updater(None).build() self.application.add_handler(TypeHandler(Update, self.handle_update)) - super().__init__(hass, config) + super().__init__(hass, config, bot) self.base_url = config.data.get(CONF_URL) or get_url( hass, require_ssl=True, allow_internal=False ) - self.webhook_url = f"{self.base_url}{TELEGRAM_WEBHOOK_URL}" + self.webhook_url = self.base_url + _get_webhook_url(bot) async def shutdown(self) -> None: """Shutdown the app.""" @@ -98,9 +98,11 @@ class PushBot(BaseTelegramBot): api_kwargs={"secret_token": self.secret_token}, connect_timeout=5, ) - except TelegramError: + except TelegramError as err: retry_num += 1 - _LOGGER.warning("Error trying to set webhook (retry #%d)", retry_num) + _LOGGER.warning( + "Error trying to set webhook (retry #%d)", retry_num, exc_info=err + ) return False @@ -143,7 +145,6 @@ class PushBotView(HomeAssistantView): """View for handling webhook calls from Telegram.""" requires_auth = False - url = TELEGRAM_WEBHOOK_URL name = "telegram_webhooks" def __init__( @@ -160,6 +161,7 @@ class PushBotView(HomeAssistantView): self.application = application self.trusted_networks = trusted_networks self.secret_token = secret_token + self.url = _get_webhook_url(bot) async def post(self, request: HomeAssistantRequest) -> Response | None: """Accept the POST from telegram.""" @@ -183,3 +185,7 @@ class PushBotView(HomeAssistantView): await self.application.process_update(update) return None + + +def _get_webhook_url(bot: Bot) -> str: + return f"{TELEGRAM_WEBHOOK_URL}_{bot.id}" diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index b0750a7785d..17aac10063c 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,8 +11,8 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", - "title": "Authenticate with TelldusLive" + "description": "To link your Telldus Live account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n[Link Telldus Live account]({auth_url})", + "title": "Authenticate with Telldus Live" }, "user": { "data": { diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index bac3f03afb8..9bcb656e4aa 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -21,7 +21,6 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_UNIQUE_ID, @@ -31,9 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -42,14 +39,19 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -88,33 +90,34 @@ class TemplateCodeFormat(Enum): text = CodeFormat.TEXT -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } DEFAULT_NAME = "Template Alarm Control Panel" -ALARM_CONTROL_PANEL_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional( - CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name - ): cv.enum(TemplateCodeFormat), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( + TemplateCodeFormat + ), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, + } ) +ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( +ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, @@ -136,107 +139,29 @@ LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( - LEGACY_ALARM_CONTROL_PANEL_SCHEMA + ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA ), } ) -ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( - TemplateCodeFormat - ), - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } +ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy alarm control panel configuration definitions to modern ones.""" - alarm_control_panels = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - alarm_control_panels.append(entity_conf) - - return alarm_control_panels - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template alarm control panels.""" - alarm_control_panels = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - alarm_control_panels.append( - AlarmControlPanelTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(alarm_control_panels) - - -def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: - """Rewrite option configuration to modern configuration.""" - option_config = {**option_config} - - if CONF_VALUE_TEMPLATE in option_config: - option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) - - return option_config - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) - async_add_entities( - [ - AlarmControlPanelTemplate( - hass, - validated_config, - config_entry.entry_id, - ) - ] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, ) @@ -247,27 +172,31 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_ALARM_CONTROL_PANELS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + ALARM_CONTROL_PANEL_DOMAIN, + config, + StateAlarmControlPanelEntity, + TriggerAlarmControlPanelEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_ALARM_CONTROL_PANELS, + ) + + +@callback +def async_create_preview_alarm_control_panel( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateAlarmControlPanelEntity: + """Create a preview alarm control panel.""" + return async_setup_template_preview( + hass, + name, + config, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, ) @@ -276,12 +205,13 @@ class AbstractTemplateAlarmControlPanel( ): """Representation of a templated Alarm Control Panel features.""" + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) - self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value @@ -343,18 +273,14 @@ class AbstractTemplateAlarmControlPanel( async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any): """Arm the panel to specified state with supplied script.""" - optimistic_set = False - - if self._template is None: - self._state = state - optimistic_set = True if script: await self.async_run_script( script, run_variables={ATTR_CODE: code}, context=self._context ) - if optimistic_set: + if self._attr_assumed_state: + self._state = state self.async_write_ha_state() async def async_alarm_arm_away(self, code: str | None = None) -> None: @@ -414,7 +340,7 @@ class AbstractTemplateAlarmControlPanel( ) -class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPanel): +class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlPanel): """Representation of a templated Alarm Control Panel.""" _attr_should_poll = False @@ -426,12 +352,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -442,11 +364,6 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -497,11 +414,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index f0ec64eae2a..a2c5c7d460a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -22,11 +22,8 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, - CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, CONF_SENSORS, @@ -40,9 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -53,17 +48,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import ( - CONF_ATTRIBUTES, - CONF_AVAILABILITY, - CONF_AVAILABILITY_TEMPLATE, - CONF_OBJECT_ID, - CONF_PICTURE, +from .const import CONF_AVAILABILITY_TEMPLATE +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, ) from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -73,16 +67,11 @@ CONF_AUTO_OFF = "auto_off" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } -BINARY_SENSOR_SCHEMA = vol.Schema( +BINARY_SENSOR_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_AUTO_OFF): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), @@ -91,15 +80,17 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Required(CONF_STATE): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } -).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) - -BINARY_SENSOR_CONFIG_SCHEMA = BINARY_SENSOR_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } ) -LEGACY_BINARY_SENSOR_SCHEMA = vol.All( +BINARY_SENSOR_YAML_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_SCHEMA.schema +) + +BINARY_SENSOR_CONFIG_ENTRY_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + +BINARY_SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -121,63 +112,15 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy binary sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( - LEGACY_BINARY_SENSOR_SCHEMA + BINARY_SENSOR_LEGACY_YAML_SCHEMA ), } ) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template binary sensors.""" - sensors = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - sensors.append( - BinarySensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -185,27 +128,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template binary sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerBinarySensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + BINARY_SENSOR_DOMAIN, + config, + StateBinarySensorEntity, + TriggerBinarySensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -215,27 +147,30 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options) - async_add_entities( - [BinarySensorTemplate(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateBinarySensorEntity, + BINARY_SENSOR_CONFIG_ENTRY_SCHEMA, ) @callback def async_create_preview_binary_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> BinarySensorTemplate: +) -> StateBinarySensorEntity: """Create a preview sensor.""" - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return BinarySensorTemplate(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateBinarySensorEntity, BINARY_SENSOR_CONFIG_ENTRY_SCHEMA + ) -class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): +class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -244,23 +179,15 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template binary sensor.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._template = config[CONF_STATE] + self._template: template.Template = config[CONF_STATE] self._delay_cancel = None self._delay_on = None self._delay_on_raw = config.get(CONF_DELAY_ON) self._delay_off = None self._delay_off_raw = config.get(CONF_DELAY_OFF) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_added_to_hass(self) -> None: """Restore state.""" @@ -303,11 +230,9 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): self._delay_cancel() self._delay_cancel = None - state = ( - None - if isinstance(result, TemplateError) - else template.result_as_boolean(result) - ) + state: bool | None = None + if result is not None and not isinstance(result, TemplateError): + state = template.result_as_boolean(result) if state == self._attr_is_on: return @@ -335,6 +260,7 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = BINARY_SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) @@ -347,7 +273,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity """Initialize the entity.""" super().__init__(hass, coordinator, config) - for key in (CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): + for key in (CONF_STATE, CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): if isinstance(config.get(key), template.Template): self._to_render_simple.append(key) self._parse_result.add(key) @@ -391,7 +317,9 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self._process_data() raw = self._rendered.get(CONF_STATE) - state = template.result_as_boolean(raw) + state: bool | None = None + if raw is not None: + state = template.result_as_boolean(raw) key = CONF_DELAY_ON if state else CONF_DELAY_OFF delay = self._rendered.get(key) or self._config.get(key) @@ -417,8 +345,8 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self.async_write_ha_state() return - # state without delay. None means rendering failed. - if self._attr_is_on == state or state is None or delay is None: + # state without delay. + if self._attr_is_on == state or delay is None: self._set_state(state) return @@ -442,7 +370,6 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity def _set_state(self, state, _=None): """Set up auto off.""" self._attr_is_on = state - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() if not state: diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 07aa41b3811..d84005ccc28 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,22 +3,20 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, +from homeassistant.components.button import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BUTTON_DOMAIN, + ENTITY_ID_FORMAT, + ButtonEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -26,41 +24,31 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import async_setup_template_entry, async_setup_template_platform +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False -BUTTON_SCHEMA = vol.Schema( +BUTTON_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -CONFIG_BUTTON_SCHEMA = vol.Schema( +BUTTON_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) - - -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateButtonEntity]: - """Create the Template button.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateButtonEntity(hass, definition, unique_id)) - return entities +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -70,15 +58,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template button.""" - if not discovery_info or "coordinator" in discovery_info: - raise PlatformNotReady( - "The template button platform doesn't support trigger entities" - ) - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + BUTTON_DOMAIN, + config, + StateButtonEntity, + None, + async_add_entities, + discovery_info, ) @@ -88,18 +75,20 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = CONFIG_BUTTON_SCHEMA(_options) - async_add_entities( - [TemplateButtonEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateButtonEntity, + BUTTON_CONFIG_ENTRY_SCHEMA, ) -class TemplateButtonEntity(TemplateEntity, ButtonEntity): +class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -108,17 +97,16 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): unique_id: str | None, ) -> None: """Initialize the button.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + + if TYPE_CHECKING: + assert self._attr_name is not None + # Scripts can be an empty list, therefore we need to check for None if (action := config.get(CONF_PRESS)) is not None: self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 86769a0d22a..a3311c35563 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -65,7 +65,7 @@ from . import ( weather as weather_platform, ) from .const import DOMAIN, PLATFORMS, TemplateConfig -from .helpers import async_get_blueprints +from .helpers import async_get_blueprints, rewrite_legacy_to_modern_configs PACKAGE_MERGE_HINT = "list" @@ -102,57 +102,57 @@ CONFIG_SECTION_SCHEMA = vol.All( { vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA + binary_sensor_platform.BINARY_SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA + sensor_platform.SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Optional(DOMAIN_ALARM_CONTROL_PANEL): vol.All( cv.ensure_list, - [alarm_control_panel_platform.ALARM_CONTROL_PANEL_SCHEMA], + [alarm_control_panel_platform.ALARM_CONTROL_PANEL_YAML_SCHEMA], ), vol.Optional(DOMAIN_BINARY_SENSOR): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_BUTTON): vol.All( - cv.ensure_list, [button_platform.BUTTON_SCHEMA] + cv.ensure_list, [button_platform.BUTTON_YAML_SCHEMA] ), vol.Optional(DOMAIN_COVER): vol.All( - cv.ensure_list, [cover_platform.COVER_SCHEMA] + cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), vol.Optional(DOMAIN_FAN): vol.All( - cv.ensure_list, [fan_platform.FAN_SCHEMA] + cv.ensure_list, [fan_platform.FAN_YAML_SCHEMA] ), vol.Optional(DOMAIN_IMAGE): vol.All( - cv.ensure_list, [image_platform.IMAGE_SCHEMA] + cv.ensure_list, [image_platform.IMAGE_YAML_SCHEMA] ), vol.Optional(DOMAIN_LIGHT): vol.All( - cv.ensure_list, [light_platform.LIGHT_SCHEMA] + cv.ensure_list, [light_platform.LIGHT_YAML_SCHEMA] ), vol.Optional(DOMAIN_LOCK): vol.All( - cv.ensure_list, [lock_platform.LOCK_SCHEMA] + cv.ensure_list, [lock_platform.LOCK_YAML_SCHEMA] ), vol.Optional(DOMAIN_NUMBER): vol.All( - cv.ensure_list, [number_platform.NUMBER_SCHEMA] + cv.ensure_list, [number_platform.NUMBER_YAML_SCHEMA] ), vol.Optional(DOMAIN_SELECT): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] + cv.ensure_list, [select_platform.SELECT_YAML_SCHEMA] ), vol.Optional(DOMAIN_SENSOR): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + cv.ensure_list, [sensor_platform.SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_SWITCH): vol.All( - cv.ensure_list, [switch_platform.SWITCH_SCHEMA] + cv.ensure_list, [switch_platform.SWITCH_YAML_SCHEMA] ), vol.Optional(DOMAIN_VACUUM): vol.All( - cv.ensure_list, [vacuum_platform.VACUUM_SCHEMA] + cv.ensure_list, [vacuum_platform.VACUUM_YAML_SCHEMA] ), vol.Optional(DOMAIN_WEATHER): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + cv.ensure_list, [weather_platform.WEATHER_YAML_SCHEMA] ), }, ), @@ -249,16 +249,16 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf legacy_warn_printed = False - for old_key, new_key, transform in ( + for old_key, new_key, legacy_fields in ( ( CONF_SENSORS, DOMAIN_SENSOR, - sensor_platform.rewrite_legacy_to_modern_conf, + sensor_platform.LEGACY_FIELDS, ), ( CONF_BINARY_SENSORS, DOMAIN_BINARY_SENSOR, - binary_sensor_platform.rewrite_legacy_to_modern_conf, + binary_sensor_platform.LEGACY_FIELDS, ), ): if old_key not in template_config: @@ -276,7 +276,11 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf definitions = ( list(template_config[new_key]) if new_key in template_config else [] ) - definitions.extend(transform(hass, template_config[old_key])) + definitions.extend( + rewrite_legacy_to_modern_configs( + hass, template_config[old_key], legacy_fields + ) + ) template_config = TemplateConfig({**template_config, new_key: definitions}) config_sections.append(template_config) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index e6cc377bc26..2e581628da2 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass +from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, @@ -30,6 +31,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -50,9 +52,44 @@ from .alarm_control_panel import ( CONF_DISARM_ACTION, CONF_TRIGGER_ACTION, TemplateCodeFormat, + async_create_preview_alarm_control_panel, ) from .binary_sensor import async_create_preview_binary_sensor -from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import ( + CONF_ADVANCED_OPTIONS, + CONF_AVAILABILITY, + CONF_PRESS, + CONF_TURN_OFF, + CONF_TURN_ON, + DOMAIN, +) +from .cover import ( + CLOSE_ACTION, + CONF_OPEN_AND_CLOSE, + CONF_POSITION, + OPEN_ACTION, + POSITION_ACTION, + STOP_ACTION, + async_create_preview_cover, +) +from .fan import ( + CONF_OFF_ACTION, + CONF_ON_ACTION, + CONF_PERCENTAGE, + CONF_SET_PERCENTAGE_ACTION, + CONF_SPEED_COUNT, + async_create_preview_fan, +) +from .light import ( + CONF_HS, + CONF_HS_ACTION, + CONF_LEVEL, + CONF_LEVEL_ACTION, + CONF_TEMPERATURE, + CONF_TEMPERATURE_ACTION, + async_create_preview_light, +) +from .lock import CONF_LOCK, CONF_OPEN, CONF_UNLOCK, async_create_preview_lock from .number import ( CONF_MAX, CONF_MIN, @@ -63,10 +100,22 @@ from .number import ( DEFAULT_STEP, async_create_preview_number, ) -from .select import CONF_OPTIONS, CONF_SELECT_OPTION +from .select import CONF_OPTIONS, CONF_SELECT_OPTION, async_create_preview_select from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch from .template_entity import TemplateEntity +from .vacuum import ( + CONF_FAN_SPEED, + CONF_FAN_SPEED_LIST, + SERVICE_CLEAN_SPOT, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, + async_create_preview_vacuum, +) _SCHEMA_STATE: dict[vol.Marker, Any] = { vol.Required(CONF_STATE): selector.TemplateSelector(), @@ -134,12 +183,65 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ) } + if domain == Platform.COVER: + schema |= _SCHEMA_STATE | { + vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): selector.ActionSelector(), + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): selector.ActionSelector(), + vol.Optional(STOP_ACTION): selector.ActionSelector(), + vol.Optional(CONF_POSITION): selector.TemplateSelector(), + vol.Optional(POSITION_ACTION): selector.ActionSelector(), + } + if flow_type == "config": + schema |= { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in CoverDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="cover_device_class", + sort=True, + ), + ) + } + + if domain == Platform.FAN: + schema |= _SCHEMA_STATE | { + vol.Required(CONF_ON_ACTION): selector.ActionSelector(), + vol.Required(CONF_OFF_ACTION): selector.ActionSelector(), + vol.Optional(CONF_PERCENTAGE): selector.TemplateSelector(), + vol.Optional(CONF_SET_PERCENTAGE_ACTION): selector.ActionSelector(), + vol.Optional(CONF_SPEED_COUNT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=100, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + } + if domain == Platform.IMAGE: schema |= { vol.Required(CONF_URL): selector.TemplateSelector(), vol.Optional(CONF_VERIFY_SSL, default=True): selector.BooleanSelector(), } + if domain == Platform.LIGHT: + schema |= _SCHEMA_STATE | { + vol.Required(CONF_TURN_ON): selector.ActionSelector(), + vol.Required(CONF_TURN_OFF): selector.ActionSelector(), + vol.Optional(CONF_LEVEL): selector.TemplateSelector(), + vol.Optional(CONF_LEVEL_ACTION): selector.ActionSelector(), + vol.Optional(CONF_HS): selector.TemplateSelector(), + vol.Optional(CONF_HS_ACTION): selector.ActionSelector(), + vol.Optional(CONF_TEMPERATURE): selector.TemplateSelector(), + vol.Optional(CONF_TEMPERATURE_ACTION): selector.ActionSelector(), + } + + if domain == Platform.LOCK: + schema |= _SCHEMA_STATE | { + vol.Required(CONF_LOCK): selector.ActionSelector(), + vol.Required(CONF_UNLOCK): selector.ActionSelector(), + vol.Optional(CONF_CODE_FORMAT): selector.TemplateSelector(), + vol.Optional(CONF_OPEN): selector.ActionSelector(), + } + if domain == Platform.NUMBER: schema |= { vol.Required(CONF_STATE): selector.TemplateSelector(), @@ -213,7 +315,37 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), } - schema[vol.Optional(CONF_DEVICE_ID)] = selector.DeviceSelector() + if domain == Platform.VACUUM: + schema |= _SCHEMA_STATE | { + vol.Required(SERVICE_START): selector.ActionSelector(), + vol.Optional(CONF_FAN_SPEED): selector.TemplateSelector(), + vol.Optional(CONF_FAN_SPEED_LIST): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(SERVICE_SET_FAN_SPEED): selector.ActionSelector(), + vol.Optional(SERVICE_STOP): selector.ActionSelector(), + vol.Optional(SERVICE_PAUSE): selector.ActionSelector(), + vol.Optional(SERVICE_RETURN_TO_BASE): selector.ActionSelector(), + vol.Optional(SERVICE_CLEAN_SPOT): selector.ActionSelector(), + vol.Optional(SERVICE_LOCATE): selector.ActionSelector(), + } + + schema |= { + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_ADVANCED_OPTIONS): section( + vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), + } + ), + {"collapsed": True}, + ), + } return vol.Schema(schema) @@ -305,20 +437,26 @@ def validate_user_input( TEMPLATE_TYPES = [ - "alarm_control_panel", - "binary_sensor", - "button", - "image", - "number", - "select", - "sensor", - "switch", + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.COVER, + Platform.FAN, + Platform.IMAGE, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, ] CONFIG_FLOW = { "user": SchemaFlowMenuStep(TEMPLATE_TYPES), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( config_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -330,10 +468,31 @@ CONFIG_FLOW = { config_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.COVER: SchemaFlowFormStep( + config_schema(Platform.COVER), + preview="template", + validate_user_input=validate_user_input(Platform.COVER), + ), + Platform.FAN: SchemaFlowFormStep( + config_schema(Platform.FAN), + preview="template", + validate_user_input=validate_user_input(Platform.FAN), + ), Platform.IMAGE: SchemaFlowFormStep( config_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), + Platform.LIGHT: SchemaFlowFormStep( + config_schema(Platform.LIGHT), + preview="template", + validate_user_input=validate_user_input(Platform.LIGHT), + ), + Platform.LOCK: SchemaFlowFormStep( + config_schema(Platform.LOCK), + preview="template", + validate_user_input=validate_user_input(Platform.LOCK), + ), Platform.NUMBER: SchemaFlowFormStep( config_schema(Platform.NUMBER), preview="template", @@ -341,6 +500,7 @@ CONFIG_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( config_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -353,6 +513,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SWITCH), ), + Platform.VACUUM: SchemaFlowFormStep( + config_schema(Platform.VACUUM), + preview="template", + validate_user_input=validate_user_input(Platform.VACUUM), + ), } @@ -360,6 +525,7 @@ OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( options_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -371,10 +537,31 @@ OPTIONS_FLOW = { options_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.COVER: SchemaFlowFormStep( + options_schema(Platform.COVER), + preview="template", + validate_user_input=validate_user_input(Platform.COVER), + ), + Platform.FAN: SchemaFlowFormStep( + options_schema(Platform.FAN), + preview="template", + validate_user_input=validate_user_input(Platform.FAN), + ), Platform.IMAGE: SchemaFlowFormStep( options_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), + Platform.LIGHT: SchemaFlowFormStep( + options_schema(Platform.LIGHT), + preview="template", + validate_user_input=validate_user_input(Platform.LIGHT), + ), + Platform.LOCK: SchemaFlowFormStep( + options_schema(Platform.LOCK), + preview="template", + validate_user_input=validate_user_input(Platform.LOCK), + ), Platform.NUMBER: SchemaFlowFormStep( options_schema(Platform.NUMBER), preview="template", @@ -382,6 +569,7 @@ OPTIONS_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( options_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -394,16 +582,28 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SWITCH), ), + Platform.VACUUM: SchemaFlowFormStep( + options_schema(Platform.VACUUM), + preview="template", + validate_user_input=validate_user_input(Platform.VACUUM), + ), } CREATE_PREVIEW_ENTITY: dict[ str, Callable[[HomeAssistant, str, dict[str, Any]], TemplateEntity], ] = { - "binary_sensor": async_create_preview_binary_sensor, - "number": async_create_preview_number, - "sensor": async_create_preview_sensor, - "switch": async_create_preview_switch, + Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, + Platform.BINARY_SENSOR: async_create_preview_binary_sensor, + Platform.COVER: async_create_preview_cover, + Platform.FAN: async_create_preview_fan, + Platform.LIGHT: async_create_preview_light, + Platform.LOCK: async_create_preview_lock, + Platform.NUMBER: async_create_preview_number, + Platform.SELECT: async_create_preview_select, + Platform.SENSOR: async_create_preview_sensor, + Platform.SWITCH: async_create_preview_switch, + Platform.VACUUM: async_create_preview_vacuum, } @@ -521,7 +721,11 @@ def ws_start_preview( ) return - preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) + config: dict = msg["user_input"] + advanced_options = config.pop(CONF_ADVANCED_OPTIONS, {}) + preview_entity = CREATE_PREVIEW_ENTITY[template_type]( + hass, name, {**config, **advanced_options} + ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 53c0fa3af13..2180567bf59 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -1,8 +1,12 @@ """Constants for the Template Platform Components.""" -from homeassistant.const import Platform +import voluptuous as vol + +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, Platform +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +CONF_ADVANCED_OPTIONS = "advanced_options" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" @@ -16,6 +20,15 @@ CONF_STEP = "step" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + DOMAIN = "template" PLATFORM_STORAGE_KEY = "template_platforms" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 68645c718b2..44981fcb08f 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -18,13 +18,13 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_COVERS, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_NAME, - CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -32,19 +32,26 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -85,7 +92,7 @@ TILT_FEATURES = ( | CoverEntityFeature.SET_TILT_POSITION ) -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, CONF_POSITION_TEMPLATE: CONF_POSITION, CONF_TILT_TEMPLATE: CONF_TILT, @@ -93,26 +100,33 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Cover" -COVER_SCHEMA = vol.All( +COVER_COMMON_SCHEMA = vol.Schema( + { + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_POSITION): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TILT): cv.template, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + } +) + +COVER_YAML_SCHEMA = vol.All( vol.Schema( { - vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, - vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_POSITION): cv.template, - vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT): cv.template, - vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), + ) + .extend(COVER_COMMON_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) -LEGACY_COVER_SCHEMA = vol.All( +COVER_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -123,7 +137,6 @@ LEGACY_COVER_SCHEMA = vol.All( vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, @@ -131,61 +144,20 @@ LEGACY_COVER_SCHEMA = vol.All( vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, } - ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), + ) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(LEGACY_COVER_SCHEMA)} + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_LEGACY_YAML_SCHEMA)} ) - -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - covers = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - covers.append(entity_conf) - - return covers - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - covers = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - covers.append( - CoverTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(covers) +COVER_CONFIG_ENTRY_SCHEMA = vol.All( + COVER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema), + cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), +) async def async_setup_platform( @@ -195,47 +167,66 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_COVERS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerCoverEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + COVER_DOMAIN, + config, + StateCoverEntity, + TriggerCoverEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_COVERS, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateCoverEntity, + COVER_CONFIG_ENTRY_SCHEMA, + True, + ) + + +@callback +def async_create_preview_cover( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateCoverEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateCoverEntity, + COVER_CONFIG_ENTRY_SCHEMA, + True, ) class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + _extra_optimistic_options = (CONF_POSITION,) + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) self._position_template = config.get(CONF_POSITION) self._tilt_template = config.get(CONF_TILT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - optimistic = config.get(CONF_OPTIMISTIC) - self._optimistic = optimistic or ( - optimistic is None and not self._template and not self._position_template - ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template self._position: int | None = None @@ -377,7 +368,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": 100}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 100 self.async_write_ha_state() @@ -391,7 +382,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": 0}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 0 self.async_write_ha_state() @@ -408,7 +399,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": self._position}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self.async_write_ha_state() async def async_open_cover_tilt(self, **kwargs: Any) -> None: @@ -445,7 +436,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): self.async_write_ha_state() -class CoverTemplate(TemplateEntity, AbstractTemplateCover): +class StateCoverEntity(TemplateEntity, AbstractTemplateCover): """Representation of a Template cover.""" _attr_should_poll = False @@ -457,12 +448,8 @@ class CoverTemplate(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateCover.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -556,10 +543,9 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): updater(rendered) write_ha_state = True - if not self._optimistic: - self.async_set_context(self.coordinator.data["context"]) + if not self._attr_assumed_state: write_ha_state = True - elif self._optimistic and len(self._rendered) > 0: + elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template write_ha_state = True diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 3617d9acdee..4901a7a7be8 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -1,32 +1,72 @@ """Template entity base class.""" +from abc import abstractmethod from collections.abc import Sequence from typing import Any +from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType -from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.template import Template, TemplateStateFromEntityId +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_OBJECT_ID class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" - def __init__(self, hass: HomeAssistant) -> None: + _entity_id_format: str + _optimistic_entity: bool = False + _extra_optimistic_options: tuple[str, ...] | None = None + _template: Template | None = None + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if self._optimistic_entity: + optimistic = config.get(CONF_OPTIMISTIC) + + self._template = config.get(CONF_STATE) + + assumed_optimistic = self._template is None + if self._extra_optimistic_options: + assumed_optimistic = assumed_optimistic and all( + config.get(option) is None + for option in self._extra_optimistic_options + ) + + self._attr_assumed_state = optimistic or ( + optimistic is None and assumed_optimistic + ) + + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + self._entity_id_format, object_id, hass=self.hass + ) + + device_registry = dr.async_get(hass) + if (device_id := config.get(CONF_DEVICE_ID)) is not None: + self.device_entry = device_registry.async_get(device_id) + @property + @abstractmethod def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - raise NotImplementedError @callback + @abstractmethod def _render_script_variables(self) -> dict: """Render configured variables.""" - raise NotImplementedError def add_script( self, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index f7b0b57cf27..9504ba45ab9 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -20,6 +20,7 @@ from homeassistant.components.fan import ( FanEntity, FanEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -34,19 +35,26 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -73,7 +81,7 @@ CONF_OSCILLATING = "oscillating" CONF_PERCENTAGE = "percentage" CONF_PRESET_MODE = "preset_mode" -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_DIRECTION_TEMPLATE: CONF_DIRECTION, CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING, CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE, @@ -83,27 +91,29 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Fan" -FAN_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_DIRECTION): cv.template, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OSCILLATING): cv.template, - vol.Optional(CONF_PERCENTAGE): cv.template, - vol.Optional(CONF_PRESET_MODE): cv.template, - vol.Optional(CONF_PRESET_MODES): cv.ensure_list, - vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), - vol.Optional(CONF_STATE): cv.template, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +FAN_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DIRECTION): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OSCILLATING): cv.template, + vol.Optional(CONF_PERCENTAGE): cv.template, + vol.Optional(CONF_PRESET_MODE): cv.template, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), + vol.Optional(CONF_STATE): cv.template, + } ) -LEGACY_FAN_SCHEMA = vol.All( +FAN_YAML_SCHEMA = FAN_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) + +FAN_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -128,56 +138,12 @@ LEGACY_FAN_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_FANS): cv.schema_with_slug_keys(LEGACY_FAN_SCHEMA)} + {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_LEGACY_YAML_SCHEMA)} ) - -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy fan configuration definitions to modern ones.""" - fans = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - fans.append(entity_conf) - - return fans - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template fans.""" - fans = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - fans.append( - TemplateFan( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) +FAN_CONFIG_ENTRY_SCHEMA = FAN_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) async def async_setup_platform( @@ -187,39 +153,58 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_FANS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerFanEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + FAN_DOMAIN, + config, + StateFanEntity, + TriggerFanEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_FANS, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateFanEntity, + FAN_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_fan( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateFanEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateFanEntity, + FAN_CONFIG_ENTRY_SCHEMA, ) class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - - self._template = config.get(CONF_STATE) self._percentage_template = config.get(CONF_PERCENTAGE) self._preset_mode_template = config.get(CONF_PRESET_MODE) self._oscillating_template = config.get(CONF_OSCILLATING) @@ -236,7 +221,6 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): # List of valid preset modes self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) - self._attr_assumed_state = self._template is None self._attr_supported_features |= ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON @@ -398,7 +382,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): if percentage is not None: await self.async_set_percentage(percentage) - if self._template is None: + if self._attr_assumed_state: self._state = True self.async_write_ha_state() @@ -408,7 +392,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): self._action_scripts[CONF_OFF_ACTION], context=self._context ) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() @@ -423,10 +407,10 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): context=self._context, ) - if self._template is None: + if self._attr_assumed_state: self._state = percentage != 0 - if self._template is None or self._percentage_template is None: + if self._attr_assumed_state or self._percentage_template is None: self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -440,10 +424,10 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): context=self._context, ) - if self._template is None: + if self._attr_assumed_state: self._state = True - if self._template is None or self._preset_mode_template is None: + if self._attr_assumed_state or self._preset_mode_template is None: self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: @@ -484,7 +468,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): ) -class TemplateFan(TemplateEntity, AbstractTemplateFan): +class StateFanEntity(TemplateEntity, AbstractTemplateFan): """A template fan component.""" _attr_should_poll = False @@ -496,12 +480,8 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateFan.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -624,5 +604,4 @@ class TriggerFanEntity(TriggerEntity, AbstractTemplateFan): write_ha_state = True if write_ha_state: - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 2cd587de5a1..a26b7bb0df1 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -1,19 +1,67 @@ """Helpers for template integration.""" +from collections.abc import Callable +import itertools import logging +from typing import Any + +import voluptuous as vol from homeassistant.components import blueprint -from homeassistant.const import SERVICE_RELOAD +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_ICON, + CONF_ICON_TEMPLATE, + CONF_NAME, + CONF_STATE, + CONF_UNIQUE_ID, + CONF_VALUE_TEMPLATE, + SERVICE_RELOAD, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, + async_get_platforms, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import ( + CONF_ADVANCED_OPTIONS, + CONF_ATTRIBUTE_TEMPLATES, + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_AVAILABILITY_TEMPLATE, + CONF_OBJECT_ID, + CONF_PICTURE, + DOMAIN, +) from .entity import AbstractTemplateEntity +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity DATA_BLUEPRINTS = "template_blueprints" -LOGGER = logging.getLogger(__name__) +LEGACY_FIELDS = { + CONF_ICON_TEMPLATE: CONF_ICON, + CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, + CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, + CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, + CONF_FRIENDLY_NAME: CONF_NAME, +} + +_LOGGER = logging.getLogger(__name__) + +type CreateTemplateEntitiesCallback = Callable[ + [type[TemplateEntity], AddEntitiesCallback, HomeAssistant, list[dict], str | None], + None, +] @callback @@ -59,8 +107,172 @@ def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: return blueprint.DomainBlueprints( hass, DOMAIN, - LOGGER, + _LOGGER, _blueprint_in_use, _reload_blueprint_templates, TEMPLATE_BLUEPRINT_SCHEMA, ) + + +def rewrite_legacy_to_modern_config( + hass: HomeAssistant, + entity_cfg: dict[str, Any], + extra_legacy_fields: dict[str, str], +) -> dict[str, Any]: + """Rewrite legacy config.""" + entity_cfg = {**entity_cfg} + + for from_key, to_key in itertools.chain( + LEGACY_FIELDS.items(), extra_legacy_fields.items() + ): + if from_key not in entity_cfg or to_key in entity_cfg: + continue + + val = entity_cfg.pop(from_key) + if isinstance(val, str): + val = template.Template(val, hass) + entity_cfg[to_key] = val + + if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): + entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass) + + return entity_cfg + + +def rewrite_legacy_to_modern_configs( + hass: HomeAssistant, + entity_cfg: dict[str, dict], + extra_legacy_fields: dict[str, str], +) -> list[dict]: + """Rewrite legacy configuration definitions to modern ones.""" + entities = [] + for object_id, entity_conf in entity_cfg.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_legacy_to_modern_config( + hass, entity_conf, extra_legacy_fields + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + entities.append(entity_conf) + + return entities + + +@callback +def async_create_template_tracking_entities( + entity_cls: type[Entity], + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template tracking entities.""" + entities: list[Entity] = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append(entity_cls(hass, definition, unique_id)) # type: ignore[call-arg] + async_add_entities(entities) + + +async def async_setup_template_platform( + hass: HomeAssistant, + domain: str, + config: ConfigType, + state_entity_cls: type[TemplateEntity], + trigger_entity_cls: type[TriggerEntity] | None, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None, + legacy_fields: dict[str, str] | None = None, + legacy_key: str | None = None, +) -> None: + """Set up the Template platform.""" + if discovery_info is None: + # Legacy Configuration + if legacy_fields is not None: + if legacy_key: + configs = rewrite_legacy_to_modern_configs( + hass, config[legacy_key], legacy_fields + ) + else: + configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)] + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + configs, + None, + ) + else: + _LOGGER.warning( + "Template %s entities can only be configured under template:", domain + ) + return + + # Trigger Configuration + if "coordinator" in discovery_info: + if trigger_entity_cls: + entities = [ + trigger_entity_cls(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ] + async_add_entities(entities) + else: + raise PlatformNotReady( + f"The template {domain} platform doesn't support trigger entities" + ) + return + + # Modern Configuration + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) + + +async def async_setup_template_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + state_entity_cls: type[TemplateEntity], + config_schema: vol.Schema | vol.All, + replace_value_template: bool = False, +) -> None: + """Setup the Template from a config entry.""" + options = dict(config_entry.options) + options.pop("template_type") + + if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None): + options = {**options, **advanced_options} + + if replace_value_template and CONF_VALUE_TEMPLATE in options: + options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) + + validated_config = config_schema(options) + + async_add_entities( + [state_entity_cls(hass, validated_config, config_entry.entry_id)] + ) + + +def async_setup_template_preview[T: TemplateEntity]( + hass: HomeAssistant, + name: str, + config: ConfigType, + state_entity_cls: type[T], + schema: vol.Schema | vol.All, + replace_value_template: bool = False, +) -> T: + """Setup the Template preview.""" + if replace_value_template and CONF_VALUE_TEMPLATE in config: + config[CONF_STATE] = config.pop(CONF_VALUE_TEMPLATE) + + validated_config = schema(config | {CONF_NAME: name}) + return state_entity_cls(hass, validated_config, None) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index d286a2f6b4d..b4513fc2447 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -7,19 +7,16 @@ from typing import Any import voluptuous as vol -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, - CONF_URL, - CONF_VERIFY_SSL, +from homeassistant.components.image import ( + DOMAIN as IMAGE_DOMAIN, + ENTITY_ID_FORMAT, + ImageEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -29,7 +26,9 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE +from .helpers import async_setup_template_entry, async_setup_template_platform from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, ) @@ -41,7 +40,7 @@ DEFAULT_NAME = "Template Image" GET_IMAGE_TIMEOUT = 10 -IMAGE_SCHEMA = vol.Schema( +IMAGE_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, @@ -49,27 +48,12 @@ IMAGE_SCHEMA = vol.Schema( ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) -IMAGE_CONFIG_SCHEMA = vol.Schema( +IMAGE_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) - - -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[StateImageEntity]: - """Create the template image.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(StateImageEntity(hass, definition, unique_id)) - return entities +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -79,23 +63,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template image.""" - if discovery_info is None: - _LOGGER.warning( - "Template image entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerImageEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + IMAGE_DOMAIN, + config, + StateImageEntity, + TriggerImageEntity, + async_add_entities, + discovery_info, ) @@ -105,11 +80,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = IMAGE_CONFIG_SCHEMA(_options) - async_add_entities( - [StateImageEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateImageEntity, + IMAGE_CONFIG_ENTRY_SCHEMA, ) @@ -118,6 +94,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): _attr_should_poll = False _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -126,13 +103,9 @@ class StateImageEntity(TemplateEntity, ImageEntity): unique_id: str | None, ) -> None: """Initialize the image.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @property def entity_picture(self) -> str | None: @@ -162,6 +135,7 @@ class TriggerImageEntity(TriggerEntity, ImageEntity): """Image entity based on trigger data.""" _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT domain = IMAGE_DOMAIN extra_template_keys = (CONF_URL,) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 10870462bc9..538d3f3aaaf 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -27,6 +27,7 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_EFFECT, CONF_ENTITY_ID, @@ -43,20 +44,27 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -103,7 +111,7 @@ CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_COLOR_ACTION: CONF_HS_ACTION, CONF_COLOR_TEMPLATE: CONF_HS, CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, @@ -123,7 +131,7 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Light" -LIGHT_SCHEMA = vol.Schema( +LIGHT_COMMON_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, @@ -134,6 +142,10 @@ LIGHT_SCHEMA = vol.Schema( vol.Optional(CONF_LEVEL): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.template, vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB): cv.template, vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, @@ -144,12 +156,14 @@ LIGHT_SCHEMA = vol.Schema( vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE): cv.template, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, } +) + +LIGHT_YAML_SCHEMA = LIGHT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -LEGACY_LIGHT_SCHEMA = vol.All( +LIGHT_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -188,50 +202,13 @@ PLATFORM_SCHEMA = vol.All( cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)} + {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_LEGACY_YAML_SCHEMA)} ), ) - -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - lights = [] - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - lights.append(entity_conf) - - return lights - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the Template Lights.""" - lights = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - lights.append(LightTemplate(hass, entity_conf, unique_id)) - - async_add_entities(lights) +LIGHT_CONFIG_ENTRY_SCHEMA = LIGHT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) async def async_setup_platform( @@ -241,33 +218,56 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLightEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LIGHT_DOMAIN, + config, + StateLightEntity, + TriggerLightEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_LIGHTS, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateLightEntity, + LIGHT_CONFIG_ENTRY_SCHEMA, + True, + ) + + +@callback +def async_create_preview_light( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateLightEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateLightEntity, + LIGHT_CONFIG_ENTRY_SCHEMA, + True, ) class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__( # pylint: disable=super-init-not-called @@ -276,7 +276,6 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Initialize the features.""" # Template attributes - self._template = config.get(CONF_STATE) self._level_template = config.get(CONF_LEVEL) self._temperature_template = config.get(CONF_TEMPERATURE) self._hs_template = config.get(CONF_HS) @@ -401,7 +400,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): Returns True if any attribute was updated. """ optimistic_set = False - if self._template is None: + if self._attr_assumed_state: self._state = True optimistic_set = True @@ -934,7 +933,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.TRANSITION -class LightTemplate(TemplateEntity, AbstractTemplateLight): +class StateLightEntity(TemplateEntity, AbstractTemplateLight): """Representation of a templated Light, including dimmable.""" _attr_should_poll = False @@ -946,12 +945,8 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLight.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -1122,7 +1117,7 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight): ) else: await self.async_run_script(off_script, context=self._context) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() @@ -1222,7 +1217,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): raw = self._rendered.get(CONF_STATE) self._state = template.result_as_boolean(raw) - self.async_set_context(self.coordinator.data["context"]) write_ha_state = True elif self._optimistic and len(self._rendered) > 0: # In case any non optimistic template @@ -1262,6 +1256,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): ) else: await self.async_run_script(off_script, context=self._context) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 1ec8b7f7535..04d26521ef1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -9,11 +9,13 @@ import voluptuous as vol from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, + ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, LockState, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, CONF_NAME, @@ -25,18 +27,26 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_PICTURE, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -49,25 +59,24 @@ CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT, CONF_VALUE_TEMPLATE: CONF_STATE, } -LOCK_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_CODE_FORMAT): cv.template, - vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_PICTURE): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +LOCK_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CODE_FORMAT): cv.template, + vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + } ) +LOCK_YAML_SCHEMA = LOCK_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( { @@ -82,32 +91,9 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( } ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template fans.""" - fans = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - fans.append( - TemplateLock( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) +LOCK_CONFIG_ENTRY_SCHEMA = LOCK_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) async def async_setup_platform( @@ -117,45 +103,62 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - [rewrite_common_legacy_to_modern_conf(hass, config, LEGACY_FIELDS)], - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLockEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LOCK_DOMAIN, + config, + StateLockEntity, + TriggerLockEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateLockEntity, + LOCK_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_lock( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateLockEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateLockEntity, + LOCK_CONFIG_ENTRY_SCHEMA, ) class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" self._state: LockState | None = None - self._state_template = config.get(CONF_STATE) self._code_format_template = config.get(CONF_CODE_FORMAT) self._code_format: str | None = None self._code_format_template_error: TemplateError | None = None - self._optimistic = config.get(CONF_OPTIMISTIC) - self._attr_assumed_state = bool(self._optimistic) def _iterate_scripts( self, config: dict[str, Any] @@ -193,6 +196,11 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Return true if lock is open.""" return self._state == LockState.OPEN + @property + def is_opening(self) -> bool: + """Return true if lock is opening.""" + return self._state == LockState.OPENING + @property def code_format(self) -> str | None: """Regex for code format or None if no code is required.""" @@ -244,7 +252,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.LOCKED self.async_write_ha_state() @@ -262,7 +270,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.UNLOCKED self.async_write_ha_state() @@ -280,7 +288,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.OPEN self.async_write_ha_state() @@ -306,7 +314,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): ) -class TemplateLock(TemplateEntity, AbstractTemplateLock): +class StateLockEntity(TemplateEntity, AbstractTemplateLock): """Representation of a template lock.""" _attr_should_poll = False @@ -318,7 +326,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: @@ -343,11 +351,13 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock): @callback def _async_setup_templates(self) -> None: """Set up templates.""" - if TYPE_CHECKING: - assert self._state_template is not None - self.add_template_attribute( - "_state", self._state_template, None, self._update_state - ) + if self._template is not None: + self.add_template_attribute( + "_state", + self._template, + None, + self._update_state, + ) if self._code_format_template: self.add_template_attribute( "_code_format_template", @@ -362,7 +372,6 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): """Lock entity based on trigger data.""" domain = LOCK_DOMAIN - extra_template_keys = (CONF_STATE,) def __init__( self, @@ -376,6 +385,9 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + if CONF_STATE in config: + self._to_render_simple.append(CONF_STATE) + if isinstance(config.get(CONF_CODE_FORMAT), template.Template): self._to_render_simple.append(CONF_CODE_FORMAT) self._parse_result.add(CONF_CODE_FORMAT) @@ -404,10 +416,9 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): updater(rendered) write_ha_state = True - if not self._optimistic: - self.async_set_context(self.coordinator.data["context"]) + if not self._attr_assumed_state: write_ha_state = True - elif self._optimistic and len(self._rendered) > 0: + elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template write_ha_state = True diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 4d9eaff0b2d..362a7e9d5c5 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -13,20 +13,13 @@ from homeassistant.components.number import ( DEFAULT_MIN_VALUE, DEFAULT_STEP, DOMAIN as NUMBER_DOMAIN, + ENTITY_ID_FORMAT, NumberEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_OPTIMISTIC, - CONF_STATE, - CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, -) +from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -35,7 +28,18 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -45,44 +49,26 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False -NUMBER_SCHEMA = vol.Schema( +NUMBER_COMMON_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -NUMBER_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_STEP): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_MIN): cv.template, - vol.Optional(CONF_MAX): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } + +NUMBER_YAML_SCHEMA = NUMBER_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + +NUMBER_CONFIG_ENTRY_SCHEMA = NUMBER_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateNumber]: - """Create the Template number.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateNumber(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -90,23 +76,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template number.""" - if discovery_info is None: - _LOGGER.warning( - "Template number entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerNumberEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + NUMBER_DOMAIN, + config, + StateNumberEntity, + TriggerNumberEntity, + async_add_entities, + discovery_info, ) @@ -116,85 +93,47 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = NUMBER_CONFIG_SCHEMA(_options) - async_add_entities([TemplateNumber(hass, validated_config, config_entry.entry_id)]) + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateNumberEntity, + NUMBER_CONFIG_ENTRY_SCHEMA, + ) @callback def async_create_preview_number( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> TemplateNumber: +) -> StateNumberEntity: """Create a preview number.""" - validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return TemplateNumber(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateNumberEntity, NUMBER_CONFIG_ENTRY_SCHEMA + ) -class TemplateNumber(TemplateEntity, NumberEntity): - """Representation of a template number.""" +class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity): + """Representation of a template number features.""" - _attr_should_poll = False - - def __init__( - self, - hass: HomeAssistant, - config, - unique_id: str | None, - ) -> None: - """Initialize the number.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None - self._value_template = config[CONF_STATE] - self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" self._step_template = config[CONF_STEP] - self._min_value_template = config[CONF_MIN] - self._max_value_template = config[CONF_MAX] - self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC) + self._min_template = config[CONF_MIN] + self._max_template = config[CONF_MAX] + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - self.add_template_attribute( - "_attr_native_value", - self._value_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - self.add_template_attribute( - "_attr_native_step", - self._step_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - if self._min_value_template is not None: - self.add_template_attribute( - "_attr_native_min_value", - self._min_value_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - if self._max_value_template is not None: - self.add_template_attribute( - "_attr_native_max_value", - self._max_value_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - super()._async_setup_templates() async def async_set_native_value(self, value: float) -> None: """Set value of the number.""" - if self._optimistic: + if self._attr_assumed_state: self._attr_native_value = value self.async_write_ha_state() if set_value := self._action_scripts.get(CONF_SET_VALUE): @@ -205,16 +144,65 @@ class TemplateNumber(TemplateEntity, NumberEntity): ) -class TriggerNumberEntity(TriggerEntity, NumberEntity): +class StateNumberEntity(TemplateEntity, AbstractTemplateNumber): + """Representation of a template number.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: + """Initialize the number.""" + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateNumber.__init__(self, config) + + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template is not None: + self.add_template_attribute( + "_attr_native_value", + self._template, + vol.Coerce(float), + none_on_template_error=True, + ) + if self._step_template is not None: + self.add_template_attribute( + "_attr_native_step", + self._step_template, + vol.Coerce(float), + none_on_template_error=True, + ) + if self._min_template is not None: + self.add_template_attribute( + "_attr_native_min_value", + self._min_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._max_template is not None: + self.add_template_attribute( + "_attr_native_max_value", + self._max_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + super()._async_setup_templates() + + +class TriggerNumberEntity(TriggerEntity, AbstractTemplateNumber): """Number entity based on trigger data.""" domain = NUMBER_DOMAIN - extra_template_keys = ( - CONF_STATE, - CONF_STEP, - CONF_MIN, - CONF_MAX, - ) def __init__( self, @@ -223,47 +211,49 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): config: dict, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateNumber.__init__(self, config) - name = self._rendered.get(CONF_NAME, DEFAULT_NAME) - self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) + for key in ( + CONF_STATE, + CONF_STEP, + CONF_MIN, + CONF_MAX, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - - @property - def native_value(self) -> float | None: - """Return the currently selected option.""" - return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE)) - - @property - def native_min_value(self) -> int: - """Return the minimum value.""" - return vol.Any(vol.Coerce(float), None)( - self._rendered.get(CONF_MIN, super().native_min_value) + self.add_script( + CONF_SET_VALUE, + config[CONF_SET_VALUE], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + DOMAIN, ) - @property - def native_max_value(self) -> int: - """Return the maximum value.""" - return vol.Any(vol.Coerce(float), None)( - self._rendered.get(CONF_MAX, super().native_max_value) - ) + def _handle_coordinator_update(self): + """Handle updated data from the coordinator.""" + self._process_data() - @property - def native_step(self) -> int: - """Return the increment/decrement step.""" - return vol.Any(vol.Coerce(float), None)( - self._rendered.get(CONF_STEP, super().native_step) - ) - - async def async_set_native_value(self, value: float) -> None: - """Set value of the number.""" - if self._config[CONF_OPTIMISTIC]: - self._attr_native_value = value + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, attr in ( + (CONF_STATE, "_attr_native_value"), + (CONF_STEP, "_attr_native_step"), + (CONF_MIN, "_attr_native_min_value"), + (CONF_MAX, "_attr_native_max_value"), + ): + if (rendered := self._rendered.get(key)) is not None: + setattr(self, attr, vol.Any(vol.Coerce(float), None)(rendered)) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() - if set_value := self._action_scripts.get(CONF_SET_VALUE): - await self.async_run_script( - set_value, - run_variables={ATTR_VALUE: value}, - context=self._context, - ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 8c05e8e2592..8e298c28539 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -11,19 +11,13 @@ from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, + ENTITY_ID_FORMAT, SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_OPTIMISTIC, - CONF_STATE, - CONF_UNIQUE_ID, -) +from homeassistant.const import CONF_NAME, CONF_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -32,7 +26,18 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -41,40 +46,22 @@ CONF_OPTIONS = "options" CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" -DEFAULT_OPTIMISTIC = False -SELECT_SCHEMA = vol.Schema( +SELECT_COMMON_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Required(ATTR_OPTIONS): cv.template, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - - -SELECT_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_OPTIONS): cv.template, + vol.Optional(ATTR_OPTIONS): cv.template, vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_STATE): cv.template, } ) +SELECT_YAML_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateSelect]: - """Create the Template select.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateSelect(hass, definition, unique_id)) - return entities +SELECT_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) async def async_setup_platform( @@ -84,23 +71,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template select.""" - if discovery_info is None: - _LOGGER.warning( - "Template select entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSelectEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + SELECT_DOMAIN, + config, + TemplateSelect, + TriggerSelectEntity, + async_add_entities, + discovery_info, ) @@ -110,13 +88,54 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SELECT_CONFIG_SCHEMA(_options) - async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + TemplateSelect, + SELECT_CONFIG_ENTRY_SCHEMA, + ) -class TemplateSelect(TemplateEntity, SelectEntity): +@callback +def async_create_preview_select( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> TemplateSelect: + """Create a preview select.""" + return async_setup_template_preview( + hass, name, config, TemplateSelect, SELECT_CONFIG_ENTRY_SCHEMA + ) + + +class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): + """Representation of a template select features.""" + + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + self._options_template = config[ATTR_OPTIONS] + + self._attr_options = [] + self._attr_current_option = None + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._attr_assumed_state: + self._attr_current_option = option + self.async_write_ha_state() + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): + await self.async_run_script( + select_option, + run_variables={ATTR_OPTION: option}, + context=self._context, + ) + + +class TemplateSelect(TemplateEntity, AbstractTemplateSelect): """Representation of a template select.""" _attr_should_poll = False @@ -128,30 +147,26 @@ class TemplateSelect(TemplateEntity, SelectEntity): unique_id: str | None, ) -> None: """Initialize the select.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None - self._value_template = config[CONF_STATE] - # Scripts can be an empty list, therefore we need to check for None + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateSelect.__init__(self, config) + + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: - self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) - self._options_template = config[ATTR_OPTIONS] - self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) - self._attr_options = [] - self._attr_current_option = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) + self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) @callback def _async_setup_templates(self) -> None: """Set up templates.""" - self.add_template_attribute( - "_attr_current_option", - self._value_template, - validator=cv.string, - none_on_template_error=True, - ) + if self._template is not None: + self.add_template_attribute( + "_attr_current_option", + self._template, + validator=cv.string, + none_on_template_error=True, + ) self.add_template_attribute( "_attr_options", self._options_template, @@ -160,24 +175,11 @@ class TemplateSelect(TemplateEntity, SelectEntity): ) super()._async_setup_templates() - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - if self._optimistic: - self._attr_current_option = option - self.async_write_ha_state() - if select_option := self._action_scripts.get(CONF_SELECT_OPTION): - await self.async_run_script( - select_option, - run_variables={ATTR_OPTION: option}, - context=self._context, - ) - -class TriggerSelectEntity(TriggerEntity, SelectEntity): +class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect): """Select entity based on trigger data.""" domain = SELECT_DOMAIN - extra_template_keys = (CONF_STATE,) extra_template_keys_complex = (ATTR_OPTIONS,) def __init__( @@ -187,7 +189,12 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): config: dict, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateSelect.__init__(self, config) + + if CONF_STATE in config: + self._to_render_simple.append(CONF_STATE) + # Scripts can be an empty list, therefore we need to check for None if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script( @@ -197,24 +204,26 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): DOMAIN, ) - @property - def current_option(self) -> str | None: - """Return the currently selected option.""" - return self._rendered.get(CONF_STATE) + def _handle_coordinator_update(self): + """Handle updated data from the coordinator.""" + self._process_data() - @property - def options(self) -> list[str]: - """Return the list of available options.""" - return self._rendered.get(ATTR_OPTIONS, []) - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - if self._config[CONF_OPTIMISTIC]: - self._attr_current_option = option + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + if (options := self._rendered.get(ATTR_OPTIONS)) is not None: + self._attr_options = vol.All(cv.ensure_list, [cv.string])(options) + write_ha_state = True + + if (state := self._rendered.get(CONF_STATE)) is not None: + self._attr_current_option = cv.string(state) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: self.async_write_ha_state() - if select_option := self._action_scripts.get(CONF_SELECT_OPTION): - await self.async_run_script( - select_option, - run_variables={ATTR_OPTION: option}, - context=self._context, - ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 508c8b2aed4..ff956c50c6e 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, SensorEntity, @@ -25,7 +26,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, @@ -43,29 +43,30 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity LEGACY_FIELDS = { CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -83,29 +84,31 @@ def validate_last_reset(val): return val -SENSOR_SCHEMA = vol.All( +SENSOR_COMMON_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +) + +SENSOR_YAML_SCHEMA = vol.All( vol.Schema( { - vol.Required(CONF_STATE): cv.template, vol.Optional(ATTR_LAST_RESET): cv.template, } ) - .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) + .extend(SENSOR_COMMON_SCHEMA.schema) .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema), validate_last_reset, ) - -SENSOR_CONFIG_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } - ).extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema), +SENSOR_CONFIG_ENTRY_SCHEMA = SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -LEGACY_SENSOR_SCHEMA = vol.All( +SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -142,33 +145,14 @@ def extra_validation_checks(val): return val -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = vol.All( SENSOR_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning vol.Optional(CONF_TRIGGERS): cv.match_all, # to raise custom warning - vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(LEGACY_SENSOR_SCHEMA), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( + SENSOR_LEGACY_YAML_SCHEMA + ), } ), extra_validation_checks, @@ -177,33 +161,6 @@ PLATFORM_SCHEMA = vol.All( _LOGGER = logging.getLogger(__name__) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template sensors.""" - sensors = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - sensors.append( - SensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -211,27 +168,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SENSOR_DOMAIN, + config, + StateSensorEntity, + TriggerSensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -241,25 +187,30 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SENSOR_CONFIG_SCHEMA(_options) - async_add_entities([SensorTemplate(hass, validated_config, config_entry.entry_id)]) + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSensorEntity, + SENSOR_CONFIG_ENTRY_SCHEMA, + ) @callback def async_create_preview_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SensorTemplate: +) -> StateSensorEntity: """Create a preview sensor.""" - validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return SensorTemplate(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateSensorEntity, SENSOR_CONFIG_ENTRY_SCHEMA + ) -class SensorTemplate(TemplateEntity, SensorEntity): +class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -268,7 +219,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config, unique_id) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) @@ -276,14 +227,6 @@ class SensorTemplate(TemplateEntity, SensorEntity): self._attr_last_reset_template: template.Template | None = config.get( ATTR_LAST_RESET ) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) @callback def _async_setup_templates(self) -> None: @@ -327,6 +270,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) @@ -339,6 +283,7 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Initialize.""" super().__init__(hass, coordinator, config) + self._parse_result.add(CONF_STATE) if (last_reset_template := config.get(ATTR_LAST_RESET)) is not None: if last_reset_template.is_static: self._static_rendered[ATTR_LAST_RESET] = last_reset_template.template diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 7f285b4929b..dece4580098 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,50 +1,174 @@ { + "common": { + "advanced_options": "Advanced options", + "availability": "Availability template", + "availability_description": "Defines a template to get the `available` state of the entity. If the template either fails to render or returns `True`, `\"1\"`, `\"true\"`, `\"yes\"`, `\"on\"`, `\"enable\"`, or a non-zero number, the entity will be `available`. If the template returns any other value, the entity will be `unavailable`. If not configured, the entity will always be `available`. Note that the string comparison is not case sensitive; `\"TrUe\"` and `\"yEs\"` are allowed.", + "code_format": "Code format", + "device_class": "Device class", + "device_id_description": "Select a device to link to this entity.", + "state": "State", + "turn_off": "Actions on turn off", + "turn_on": "Actions on turn on", + "unit_of_measurement": "Unit of measurement" + }, "config": { "step": { "alarm_control_panel": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "value_template": "[%key:component::template::common::state%]", "name": "[%key:common::config_flow::data::name%]", - "disarm": "Disarm action", - "arm_away": "Arm away action", - "arm_custom_bypass": "Arm custom bypass action", - "arm_home": "Arm home action", - "arm_night": "Arm night action", - "arm_vacation": "Arm vacation action", - "trigger": "Trigger action", + "disarm": "Actions on disarm", + "arm_away": "Actions on arm away", + "arm_custom_bypass": "Actions on arm custom bypass", + "arm_home": "Actions on arm home", + "arm_night": "Actions on arm night", + "arm_vacation": "Actions on arm vacation", + "trigger": "Actions on trigger", "code_arm_required": "Code arm required", - "code_format": "Code format" + "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "value_template": "Defines a template to set the state of the alarm panel. Valid output values from the template are `armed_away`, `armed_home`, `armed_night`, `armed_vacation`, `arming`, `disarmed`, `pending`, and `triggered`.", + "disarm": "Defines actions to run when the alarm control panel is disarmed. Receives variable `code`.", + "arm_away": "Defines actions to run when the alarm control panel is armed to `arm_away`. Receives variable `code`.", + "arm_custom_bypass": "Defines actions to run when the alarm control panel is armed to `arm_custom_bypass`. Receives variable `code`.", + "arm_home": "Defines actions to run when the alarm control panel is armed to `arm_home`. Receives variable `code`.", + "arm_night": "Defines actions to run when the alarm control panel is armed to `arm_night`. Receives variable `code`.", + "arm_vacation": "Defines actions to run when the alarm control panel is armed to `arm_vacation`. Receives variable `code`.", + "trigger": "Defines actions to run when the alarm control panel is triggered. Receives variable `code`.", + "code_arm_required": "If true, the code is required to arm the alarm.", + "code_format": "One of `number`, `text` or `no_code`. Format for the code used to arm/disarm the alarm." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template alarm control panel" }, "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]" + "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "The sensor is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template binary sensor" }, "button": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", "press": "Actions on press" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "press": "Defines actions to run when button is pressed." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template button" }, + "cover": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "device_class": "[%key:component::template::common::device_class%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "open_cover": "Actions on open", + "close_cover": "Actions on close", + "stop_cover": "Actions on stop", + "position": "Position", + "set_cover_position": "Actions on set position" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to get the state of the cover. Valid output values from the template are `open`, `opening`, `closing` and `closed` which are directly mapped to the corresponding states. If both a state and a position are specified, only `opening` and `closing` are set from the state template.", + "open_cover": "Defines actions to run when the cover is opened.", + "close_cover": "Defines actions to run when the cover is closed.", + "stop_cover": "Defines actions to run when the cover is stopped.", + "position": "Defines a template to get the position of the cover. Value values are numbers between `0` (`closed`) and `100` (`open`).", + "set_cover_position": "Defines actions to run when the cover is given a `set_cover_position` command. Receives variable `position`." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "Template cover" + }, + "fan": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "percentage": "Percentage", + "set_percentage": "Actions on set percentage", + "speed_count": "Speed count" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "The fan is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`.", + "turn_off": "Defines actions to run when the fan is turned off.", + "turn_on": "Defines actions to run when the fan is turned on. Receives variables `percentage` and/or `preset_mode`.", + "percentage": "Defines a template to get the speed percentage of the fan.", + "set_percentage": "Defines actions to run when the fan is given a speed percentage command. Receives variable `percentage`.", + "speed_count": "The number of speeds the fan supports. Used to calculate the percentage step for the `fan.increase_speed` and `fan.decrease_speed` actions." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "Template fan" + }, "image": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -53,23 +177,123 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "url": "Defines a template to get the URL on which the image is served.", + "verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template image" }, + "light": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "level": "Brightness level", + "set_level": "Actions on set level", + "hs": "HS color", + "set_hs": "Actions on set HS color", + "temperature": "Color temperature", + "set_temperature": "Actions on set color temperature" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "The light is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`.", + "turn_off": "Defines actions to run when the light is turned off.", + "turn_on": "Defines actions to run when the light is turned on.", + "level": "Defines a template to get the brightness of the light. Valid values are 0 to 255.", + "set_level": "Defines actions to run when the light is given a brightness command. The script will only be called if the `turn_on` call only has `brightness`, and optionally `transition`. Receives variables `brightness` and, optionally, `transition`.", + "hs": "Defines a template to get the HS color of the light. Must render a tuple (hue, saturation).", + "set_hs": "Defines actions to run when the light is given an HS color command. Available variables: `hs` as a tuple, `h` and `s`.", + "temperature": "Defines a template to get the color temperature of the light.", + "set_temperature": "Defines actions to run when the light is given a color temperature command. Receives variable `color_temp_kelvin`. May also receive variables `brightness` and/or `transition`." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "Template light" + }, + "lock": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "lock": "Actions on lock", + "unlock": "Actions on unlock", + "code_format": "[%key:component::template::common::code_format%]", + "open": "Actions on open" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to set the state of the lock. The lock is locked if the template evaluates to `True`, `true`, `on`, or `locked`. The lock is unlocked if the template evaluates to `False`, `false`, `off`, or `unlocked`. Other valid states are `jammed`, `opening`, `locking`, `open`, and `unlocking`.", + "lock": "Defines actions to run when the lock is locked.", + "unlock": "Defines actions to run when the lock is unlocked.", + "code_format": "Defines a template to get the `code_format` attribute of the lock.", + "open": "Defines actions to run when the lock is opened." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "Template lock" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "step": "Step value", "set_value": "Actions on set value", "max": "Maximum value", "min": "Minimum value", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Template for the number's current value.", + "step": "Defines the number's increment/decrement step.", + "set_value": "Defines actions to run when the number is set to a value. Receives variable `value`.", + "max": "Defines the number's maximum value.", + "min": "Defines the number's minimum value.", + "unit_of_measurement": "Defines the unit of measurement of the number, if any." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template number" }, @@ -77,26 +301,53 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "select_option": "Actions on select", "options": "Available options" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Template for the select’s current value.", + "select_option": "Defines actions to run when an `option` from the `options` list is selected. Receives variable `option`.", + "options": "Template for the select’s available options." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template select" }, "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "Device class", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", - "state": "State template", - "unit_of_measurement": "Unit of measurement" + "state": "[%key:component::template::common::state%]", + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "Select a device to link to this entity." + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to get the state of the sensor. If the sensor is numeric, i.e. it has a `state_class` or a `unit_of_measurement`, the state template must render to a number or to `none`. The state template must not render to a string, including `unknown` or `unavailable`. An `availability` template may be defined to suppress rendering of the state template.", + "unit_of_measurement": "Defines the unit of measurement for the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template sensor" }, @@ -106,11 +357,16 @@ "alarm_control_panel": "Template an alarm control panel", "binary_sensor": "Template a binary sensor", "button": "Template a button", + "cover": "Template a cover", + "fan": "Template a fan", "image": "Template an image", + "light": "Template a light", + "lock": "Template a lock", "number": "Template a number", "select": "Template a select", "sensor": "Template a sensor", - "switch": "Template a switch" + "switch": "Template a switch", + "vacuum": "Template a vacuum" }, "title": "Template helper" }, @@ -118,24 +374,84 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "turn_off": "Actions on turn off", - "turn_on": "Actions on turn on", - "value_template": "Value template" + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "value_template": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", - "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." + "device_id": "[%key:component::template::common::device_id_description%]", + "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful.", + "turn_off": "Defines actions to run when the switch is turned off.", + "turn_on": "Defines actions to run when the switch is turned on." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "Template switch" + }, + "vacuum": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "start": "Actions on start", + "fan_speed": "Fan speed", + "fan_speeds": "Fan speeds", + "set_fan_speed": "Actions on set fan speed", + "stop": "Actions on stop", + "pause": "Actions on pause", + "return_to_base": "Actions on return to dock", + "clean_spot": "Actions on clean spot", + "locate": "Actions on locate" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to get the state of the vacuum. Valid values are `cleaning`, `docked`, `idle`, `paused`, `returning`, and `error`.", + "start": "Defines actions to run when the vacuum is started.", + "fan_speed": "Defines a template to get the fan speed of the vacuum.", + "fan_speeds": "List of fan speeds supported by the vacuum.", + "set_fan_speed": "Defines actions to run when the vacuum is given a command to set the fan speed. Receives variable `fan_speed`.", + "stop": "Defines actions to run when the vacuum is stopped.", + "pause": "Defines actions to run when the vacuum is paused.", + "return_to_base": "Defines actions to run when the vacuum is given a 'Return to dock' command.", + "clean_spot": "Defines actions to run when the vacuum is given a 'Clean spot' command.", + "locate": "Defines actions to run when the vacuum is given a 'Locate' command." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "Template vacuum" } } }, + "issues": { + "deprecated_battery_level": { + "title": "Deprecated battery level option in {entity_name}", + "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name})." + } + }, "options": { "step": { "alarm_control_panel": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "value_template": "[%key:component::template::common::state%]", "disarm": "[%key:component::template::config::step::alarm_control_panel::data::disarm%]", "arm_away": "[%key:component::template::config::step::alarm_control_panel::data::arm_away%]", "arm_custom_bypass": "[%key:component::template::config::step::alarm_control_panel::data::arm_custom_bypass%]", @@ -144,20 +460,53 @@ "arm_vacation": "[%key:component::template::config::step::alarm_control_panel::data::arm_vacation%]", "trigger": "[%key:component::template::config::step::alarm_control_panel::data::trigger%]", "code_arm_required": "[%key:component::template::config::step::alarm_control_panel::data::code_arm_required%]", - "code_format": "[%key:component::template::config::step::alarm_control_panel::data::code_format%]" + "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "value_template": "[%key:component::template::config::step::alarm_control_panel::data_description::value_template%]", + "disarm": "[%key:component::template::config::step::alarm_control_panel::data_description::disarm%]", + "arm_away": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_away%]", + "arm_custom_bypass": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_custom_bypass%]", + "arm_home": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_home%]", + "arm_night": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_night%]", + "arm_vacation": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_vacation%]", + "trigger": "[%key:component::template::config::step::alarm_control_panel::data_description::trigger%]", + "code_arm_required": "[%key:component::template::config::step::alarm_control_panel::data_description::code_arm_required%]", + "code_format": "[%key:component::template::config::step::alarm_control_panel::data_description::code_format%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::alarm_control_panel::title%]" }, "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "state": "[%key:component::template::config::step::sensor::data::state%]" + "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::binary_sensor::data_description::state%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::binary_sensor::title%]" }, @@ -167,10 +516,87 @@ "press": "[%key:component::template::config::step::button::data::press%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "press": "[%key:component::template::config::step::button::data_description::press%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::button::title%]" }, + + "cover": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "state": "[%key:component::template::common::state%]", + "open_cover": "[%key:component::template::config::step::cover::data::open_cover%]", + "close_cover": "[%key:component::template::config::step::cover::data::close_cover%]", + "stop_cover": "[%key:component::template::config::step::cover::data::stop_cover%]", + "position": "[%key:component::template::config::step::cover::data::position%]", + "set_cover_position": "[%key:component::template::config::step::cover::data::set_cover_position%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::cover::data_description::state%]", + "open_cover": "[%key:component::template::config::step::cover::data_description::open_cover%]", + "close_cover": "[%key:component::template::config::step::cover::data_description::close_cover%]", + "stop_cover": "[%key:component::template::config::step::cover::data_description::stop_cover%]", + "position": "[%key:component::template::config::step::cover::data_description::position%]", + "set_cover_position": "[%key:component::template::config::step::cover::data_description::set_cover_position%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "[%key:component::template::config::step::cover::title%]" + }, + "fan": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "percentage": "[%key:component::template::config::step::fan::data::percentage%]", + "set_percentage": "[%key:component::template::config::step::fan::data::set_percentage%]", + "speed_count": "[%key:component::template::config::step::fan::data::speed_count%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::fan::data_description::state%]", + "turn_off": "[%key:component::template::config::step::fan::data_description::turn_off%]", + "turn_on": "[%key:component::template::config::step::fan::data_description::turn_on%]", + "percentage": "[%key:component::template::config::step::fan::data_description::percentage%]", + "set_percentage": "[%key:component::template::config::step::fan::data_description::set_percentage%]", + "speed_count": "[%key:component::template::config::step::fan::data_description::speed_count%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "[%key:component::template::config::step::fan::title%]" + }, "image": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -178,22 +604,120 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "url": "[%key:component::template::config::step::image::data_description::url%]", + "verify_ssl": "[%key:component::template::config::step::image::data_description::verify_ssl%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::image::title%]" }, + "light": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "level": "[%key:component::template::config::step::light::data::level%]", + "set_level": "[%key:component::template::config::step::light::data::set_level%]", + "hs": "[%key:component::template::config::step::light::data::hs%]", + "set_hs": "[%key:component::template::config::step::light::data::set_hs%]", + "temperature": "[%key:component::template::config::step::light::data::temperature%]", + "set_temperature": "[%key:component::template::config::step::light::data::set_temperature%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::light::data_description::state%]", + "turn_off": "[%key:component::template::config::step::light::data_description::turn_off%]", + "turn_on": "[%key:component::template::config::step::light::data_description::turn_on%]", + "level": "[%key:component::template::config::step::light::data_description::level%]", + "set_level": "[%key:component::template::config::step::light::data_description::set_level%]", + "hs": "[%key:component::template::config::step::light::data_description::hs%]", + "set_hs": "[%key:component::template::config::step::light::data_description::set_hs%]", + "temperature": "[%key:component::template::config::step::light::data_description::temperature%]", + "set_temperature": "[%key:component::template::config::step::light::data_description::set_temperature%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "[%key:component::template::config::step::light::title%]" + }, + "lock": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "state": "[%key:component::template::common::state%]", + "lock": "[%key:component::template::config::step::lock::data::lock%]", + "unlock": "[%key:component::template::config::step::lock::data::unlock%]", + "code_format": "[%key:component::template::common::code_format%]", + "open": "[%key:component::template::config::step::lock::data::open%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::lock::data_description::state%]", + "lock": "[%key:component::template::config::step::lock::data_description::lock%]", + "unlock": "[%key:component::template::config::step::lock::data_description::unlock%]", + "code_format": "[%key:component::template::config::step::lock::data_description::code_format%]", + "open": "[%key:component::template::config::step::lock::data_description::open%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "[%key:component::template::config::step::lock::title%]" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "step": "[%key:component::template::config::step::number::data::step%]", "set_value": "[%key:component::template::config::step::number::data::set_value%]", "max": "[%key:component::template::config::step::number::data::max%]", "min": "[%key:component::template::config::step::number::data::min%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::number::data_description::state%]", + "step": "[%key:component::template::config::step::number::data_description::step%]", + "set_value": "[%key:component::template::config::step::number::data_description::set_value%]", + "max": "[%key:component::template::config::step::number::data_description::max%]", + "min": "[%key:component::template::config::step::number::data_description::min%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::number::title%]" }, @@ -201,25 +725,52 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "select_option": "[%key:component::template::config::step::select::data::select_option%]", "options": "[%key:component::template::config::step::select::data::options%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::select::data_description::state%]", + "select_option": "[%key:component::template::config::step::select::data_description::select_option%]", + "options": "[%key:component::template::config::step::select::data_description::options%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::select::title%]" }, "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" + "state": "[%key:component::template::common::state%]", + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::sensor::data_description::state%]", + "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::unit_of_measurement%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::sensor::title%]" }, @@ -227,15 +778,69 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", - "turn_off": "[%key:component::template::config::step::switch::data::turn_off%]", - "turn_on": "[%key:component::template::config::step::switch::data::turn_on%]" + "value_template": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", - "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]", + "turn_off": "[%key:component::template::config::step::switch::data_description::turn_off%]", + "turn_on": "[%key:component::template::config::step::switch::data_description::turn_on%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } }, "title": "[%key:component::template::config::step::switch::title%]" + }, + "vacuum": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "start": "[%key:component::template::config::step::vacuum::data::start%]", + "fan_speed": "[%key:component::template::config::step::vacuum::data::fan_speed%]", + "fan_speeds": "[%key:component::template::config::step::vacuum::data::fan_speeds%]", + "set_fan_speed": "[%key:component::template::config::step::vacuum::data::set_fan_speed%]", + "stop": "[%key:component::template::config::step::vacuum::data::stop%]", + "pause": "[%key:component::template::config::step::vacuum::data::pause%]", + "return_to_base": "[%key:component::template::config::step::vacuum::data::return_to_base%]", + "clean_spot": "[%key:component::template::config::step::vacuum::data::clean_spot%]", + "locate": "[%key:component::template::config::step::vacuum::data::locate%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::vacuum::data_description::state%]", + "start": "[%key:component::template::config::step::vacuum::data_description::start%]", + "fan_speed": "[%key:component::template::config::step::vacuum::data_description::fan_speed%]", + "fan_speeds": "[%key:component::template::config::step::vacuum::data_description::fan_speeds%]", + "set_fan_speed": "[%key:component::template::config::step::vacuum::data_description::set_fan_speed%]", + "stop": "[%key:component::template::config::step::vacuum::data_description::stop%]", + "pause": "[%key:component::template::config::step::vacuum::data_description::pause%]", + "return_to_base": "[%key:component::template::config::step::vacuum::data_description::return_to_base%]", + "clean_spot": "[%key:component::template::config::step::vacuum::data_description::clean_spot%]", + "locate": "[%key:component::template::config::step::vacuum::data_description::locate%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "[%key:component::template::config::step::vacuum::title%]" } } }, @@ -286,8 +891,23 @@ "update": "[%key:component::button::entity_component::update::name%]" } }, + "cover_device_class": { + "options": { + "awning": "[%key:component::cover::entity_component::awning::name%]", + "blind": "[%key:component::cover::entity_component::blind::name%]", + "curtain": "[%key:component::cover::entity_component::curtain::name%]", + "damper": "[%key:component::cover::entity_component::damper::name%]", + "door": "[%key:component::cover::entity_component::door::name%]", + "garage": "[%key:component::cover::entity_component::garage::name%]", + "gate": "[%key:component::cover::entity_component::gate::name%]", + "shade": "[%key:component::cover::entity_component::shade::name%]", + "shutter": "[%key:component::cover::entity_component::shutter::name%]", + "window": "[%key:component::cover::entity_component::window::name%]" + } + }, "sensor_device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "area": "[%key:component::sensor::entity_component::area::name%]", @@ -335,7 +955,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 677686ea8d8..cc0fd4c7ad2 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_SWITCHES, @@ -29,9 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -40,34 +37,43 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } DEFAULT_NAME = "Template Switch" - -SWITCH_SCHEMA = vol.Schema( +SWITCH_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, } +) + +SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -LEGACY_SWITCH_SCHEMA = vol.All( +SWITCH_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -82,78 +88,14 @@ LEGACY_SWITCH_SCHEMA = vol.All( ) PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)} + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_LEGACY_YAML_SCHEMA)} ) -SWITCH_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } +SWITCH_CONFIG_ENTRY_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - switches = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - switches.append(entity_conf) - - return switches - - -def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: - """Rewrite option configuration to modern configuration.""" - option_config = {**option_config} - - if CONF_VALUE_TEMPLATE in option_config: - option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) - - return option_config - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - switches = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - switches.append( - SwitchTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(switches) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -161,27 +103,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template switches.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSwitchEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SWITCH_DOMAIN, + config, + StateSwitchEntity, + TriggerSwitchEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SWITCHES, ) @@ -191,24 +122,60 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, + ) @callback def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SwitchTemplate: +) -> StateSwitchEntity: """Create a preview switch.""" - updated_config = rewrite_options_to_modern_conf(config) - validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) - return SwitchTemplate(hass, validated_config, None) + return async_setup_template_preview( + hass, + name, + config, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, + ) -class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): +class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity): + """Representation of a template switch features.""" + + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Fire the on action.""" + if on_script := self._action_scripts.get(CONF_TURN_ON): + await self.async_run_script(on_script, context=self._context) + if self._attr_assumed_state: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Fire the off action.""" + if off_script := self._action_scripts.get(CONF_TURN_OFF): + await self.async_run_script(off_script, context=self._context) + if self._attr_assumed_state: + self._attr_is_on = False + self.async_write_ha_state() + + +class StateSwitchEntity(TemplateEntity, AbstractTemplateSwitch): """Representation of a Template switch.""" _attr_should_poll = False @@ -220,15 +187,12 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateSwitch.__init__(self, config) + name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_STATE) # Scripts can be an empty list, therefore we need to check for None if (on_action := config.get(CONF_TURN_ON)) is not None: @@ -236,29 +200,22 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): if (off_action := config.get(CONF_TURN_OFF)) is not None: self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) - self._state: bool | None = False - self._attr_assumed_state = self._template is None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - @callback def _update_state(self, result): super()._update_state(result) if isinstance(result, TemplateError): - self._state = None + self._attr_is_on = None return if isinstance(result, bool): - self._state = result + self._attr_is_on = result return if isinstance(result, str): - self._state = result.lower() in ("true", STATE_ON) + self._attr_is_on = result.lower() in ("true", STATE_ON) return - self._state = False + self._attr_is_on = False async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -266,7 +223,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): # restore state after startup await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._state = state.state == STATE_ON + self._attr_is_on = state.state == STATE_ON await super().async_added_to_hass() @callback @@ -274,34 +231,13 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): """Set up templates.""" if self._template is not None: self.add_template_attribute( - "_state", self._template, None, self._update_state + "_attr_is_on", self._template, None, self._update_state ) super()._async_setup_templates() - @property - def is_on(self) -> bool | None: - """Return true if device is on.""" - return self._state - async def async_turn_on(self, **kwargs: Any) -> None: - """Fire the on action.""" - if on_script := self._action_scripts.get(CONF_TURN_ON): - await self.async_run_script(on_script, context=self._context) - if self._template is None: - self._state = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Fire the off action.""" - if off_script := self._action_scripts.get(CONF_TURN_OFF): - await self.async_run_script(off_script, context=self._context) - if self._template is None: - self._state = False - self.async_write_ha_state() - - -class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): +class TriggerSwitchEntity(TriggerEntity, AbstractTemplateSwitch): """Switch entity based on trigger data.""" domain = SWITCH_DOMAIN @@ -313,24 +249,19 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): config: ConfigType, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateSwitch.__init__(self, config) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) - self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) if off_action := config.get(CONF_TURN_OFF): self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) - self._attr_assumed_state = self._template is None - if not self._attr_assumed_state: + if CONF_STATE in config: self._to_render_simple.append(CONF_STATE) self._parse_result.add(CONF_STATE) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -353,29 +284,15 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): self.async_write_ha_state() return - if not self._attr_assumed_state: - raw = self._rendered.get(CONF_STATE) - self._attr_is_on = template.result_as_boolean(raw) + write_ha_state = False + if (state := self._rendered.get(CONF_STATE)) is not None: + self._attr_is_on = template.result_as_boolean(state) + write_ha_state = True - self.async_set_context(self.coordinator.data["context"]) - self.async_write_ha_state() - elif self._attr_assumed_state and len(self._rendered) > 0: + elif len(self._rendered) > 0: # In case name, icon, or friendly name have a template but # states does not - self.async_write_ha_state() + write_ha_state = True - async def async_turn_on(self, **kwargs: Any) -> None: - """Fire the on action.""" - if on_script := self._action_scripts.get(CONF_TURN_ON): - await self.async_run_script(on_script, context=self._context) - if self._template is None: - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Fire the off action.""" - if off_script := self._action_scripts.get(CONF_TURN_OFF): - await self.async_run_script(off_script, context=self._context) - if self._template is None: - self._attr_is_on = False + if write_ha_state: self.async_write_ha_state() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 3157a60347e..3ba89cae1f4 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping import contextlib -import itertools import logging from typing import Any, cast @@ -13,11 +12,12 @@ import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( + CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_OPTIMISTIC, CONF_PATH, CONF_VARIABLES, STATE_UNKNOWN, @@ -32,7 +32,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, @@ -48,7 +48,6 @@ from homeassistant.helpers.template import ( result_as_boolean, ) from homeassistant.helpers.trigger_template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, make_template_entity_base_schema, ) from homeassistant.helpers.typing import ConfigType @@ -59,6 +58,7 @@ from .const import ( CONF_AVAILABILITY, CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE, + TEMPLATE_ENTITY_BASE_SCHEMA, ) from .entity import AbstractTemplateEntity @@ -93,6 +93,18 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = ( .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) ) +TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + + +TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = { + vol.Optional(CONF_OPTIMISTIC): cv.boolean, +} + def make_template_entity_common_modern_schema( default_name: str, @@ -137,42 +149,6 @@ TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, - CONF_FRIENDLY_NAME: CONF_NAME, -} - - -def rewrite_common_legacy_to_modern_conf( - hass: HomeAssistant, - entity_cfg: dict[str, Any], - extra_legacy_fields: dict[str, str] | None = None, -) -> dict[str, Any]: - """Rewrite legacy config.""" - entity_cfg = {**entity_cfg} - if extra_legacy_fields is None: - extra_legacy_fields = {} - - for from_key, to_key in itertools.chain( - LEGACY_FIELDS.items(), extra_legacy_fields.items() - ): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = Template(val, hass) - entity_cfg[to_key] = val - - if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): - entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME], hass) - - return entity_cfg - - class _TemplateAttribute: """Attribute value linked to template result.""" @@ -278,17 +254,11 @@ class TemplateEntity(AbstractTemplateEntity): def __init__( self, hass: HomeAssistant, - *, - availability_template: Template | None = None, - icon_template: Template | None = None, - entity_picture_template: Template | None = None, - attribute_templates: dict[str, Template] | None = None, - config: ConfigType | None = None, - fallback_name: str | None = None, - unique_id: str | None = None, + config: ConfigType, + unique_id: str | None, ) -> None: """Template Entity.""" - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -307,22 +277,13 @@ class TemplateEntity(AbstractTemplateEntity): | None ) = None self._run_variables: ScriptVariables | dict - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - self._run_variables = {} - self._blueprint_inputs = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - self._run_variables = config.get(CONF_VARIABLES, {}) - self._blueprint_inputs = config.get("raw_blueprint_inputs") + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + self._run_variables = config.get(CONF_VARIABLES, {}) + self._blueprint_inputs = config.get("raw_blueprint_inputs") class DummyState(State): """None-state for template entities not yet added to the state machine.""" @@ -340,7 +301,7 @@ class TemplateEntity(AbstractTemplateEntity): variables = {"this": DummyState()} # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name + self._attr_name = None if self._friendly_name_template: with contextlib.suppress(TemplateError): self._attr_name = self._friendly_name_template.async_render( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 4565e86843a..66c57eb2aab 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -30,7 +30,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._state_render_error = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1fb5b89ead2..242a534187a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -22,6 +22,7 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -33,21 +34,33 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + template, +) +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -72,31 +85,33 @@ _VALID_STATES = [ VacuumActivity.ERROR, ] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL, CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED, CONF_VALUE_TEMPLATE: CONF_STATE, } -VACUUM_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_BATTERY_LEVEL): cv.template, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, - vol.Optional(CONF_FAN_SPEED): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, - vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) +VACUUM_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_BATTERY_LEVEL): cv.template, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, + vol.Optional(CONF_FAN_SPEED): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, + vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + } ) -LEGACY_VACUUM_SCHEMA = vol.All( +VACUUM_YAML_SCHEMA = VACUUM_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) + +VACUUM_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -121,56 +136,12 @@ LEGACY_VACUUM_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(LEGACY_VACUUM_SCHEMA)} + {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(VACUUM_LEGACY_YAML_SCHEMA)} ) - -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - vacuums = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - vacuums.append(entity_conf) - - return vacuums - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - vacuums = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - vacuums.append( - TemplateVacuum( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(vacuums) +VACUUM_CONFIG_ENTRY_SCHEMA = VACUUM_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) async def async_setup_platform( @@ -179,43 +150,82 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_VACUUMS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerVacuumEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + """Set up the Template vacuum.""" + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + VACUUM_DOMAIN, + config, + TemplateStateVacuumEntity, + TriggerVacuumEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_VACUUMS, ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + TemplateStateVacuumEntity, + VACUUM_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_vacuum( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> TemplateStateVacuumEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + TemplateStateVacuumEntity, + VACUUM_CONFIG_ENTRY_SCHEMA, + ) + + +def create_issue( + hass: HomeAssistant, supported_features: int, name: str, entity_id: str +) -> None: + """Create the battery_level issue.""" + if supported_features & VacuumEntityFeature.BATTERY: + key = "deprecated_battery_level" + ir.async_create_issue( + hass, + DOMAIN, + f"{key}_{entity_id}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=key, + translation_placeholders={ + "entity_name": name, + "entity_id": entity_id, + }, + ) + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) self._battery_level_template = config.get(CONF_BATTERY_LEVEL) self._fan_speed_template = config.get(CONF_FAN_SPEED) - self._state = None self._battery_level = None self._attr_fan_speed = None @@ -244,17 +254,12 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): if (action_config := config.get(action_id)) is not None: yield (action_id, action_config, supported_feature) - @property - def activity(self) -> VacuumActivity | None: - """Return the status of the vacuum cleaner.""" - return self._state - def _handle_state(self, result: Any) -> None: # Validate state if result in _VALID_STATES: - self._state = result + self._attr_activity = result elif result == STATE_UNKNOWN: - self._state = None + self._attr_activity = None else: _LOGGER.error( "Received invalid vacuum state: %s for entity %s. Expected: %s", @@ -262,31 +267,46 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self.entity_id, ", ".join(_VALID_STATES), ) - self._state = None + self._attr_activity = None async def async_start(self) -> None: """Start or resume the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() await self.async_run_script( self._action_scripts[SERVICE_START], context=self._context ) async def async_pause(self) -> None: """Pause the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.PAUSED + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_PAUSE): await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.IDLE + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_STOP): await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.RETURNING + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_RETURN_TO_BASE): await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_CLEAN_SPOT): await self.async_run_script(script, context=self._context) @@ -333,7 +353,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): if isinstance(fan_speed, TemplateError): # This is legacy behavior self._attr_fan_speed = None - self._state = None + self._attr_activity = None return if fan_speed in self._attr_fan_speed_list: @@ -350,7 +370,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self._attr_fan_speed = None -class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): +class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): """A template vacuum component.""" _attr_should_poll = False @@ -362,12 +382,8 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateVacuum.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -378,12 +394,22 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _async_setup_templates(self) -> None: """Set up templates.""" if self._template is not None: self.add_template_attribute( - "_state", self._template, None, self._update_state + "_attr_activity", self._template, None, self._update_state ) if self._fan_speed_template is not None: self.add_template_attribute( @@ -407,7 +433,7 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): super()._update_state(result) if isinstance(result, TemplateError): # This is legacy behavior - self._state = STATE_UNKNOWN + self._attr_activity = None if not self._availability_template: self._attr_available = True return @@ -443,6 +469,16 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): self._to_render_simple.append(key) self._parse_result.add(key) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _handle_coordinator_update(self) -> None: """Handle update of the data.""" @@ -467,5 +503,4 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): write_ha_state = True if write_ha_state: - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index ee834e757a3..bddb55197c3 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -32,6 +32,7 @@ from homeassistant.components.weather import ( WeatherEntityFeature, ) from homeassistant.const import ( + CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID, STATE_UNAVAILABLE, @@ -40,7 +41,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -52,11 +52,8 @@ from homeassistant.util.unit_conversion import ( ) from .coordinator import TriggerUpdateCoordinator -from .template_entity import ( - TemplateEntity, - make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( @@ -94,6 +91,7 @@ CONF_PRESSURE_TEMPLATE = "pressure_template" CONF_WIND_SPEED_TEMPLATE = "wind_speed_template" CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" +CONF_UV_INDEX_TEMPLATE = "uv_index_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" @@ -109,7 +107,7 @@ CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" DEFAULT_NAME = "Template Weather" -WEATHER_SCHEMA = vol.Schema( +WEATHER_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, @@ -126,6 +124,7 @@ WEATHER_SCHEMA = vol.Schema( vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_UV_INDEX_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, @@ -135,34 +134,33 @@ WEATHER_SCHEMA = vol.Schema( } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the weather entities.""" - entities = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - entities.append( - WeatherTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(entities) +PLATFORM_SCHEMA = vol.Schema( + { + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + } +).extend(WEATHER_PLATFORM_SCHEMA.schema) async def async_setup_platform( @@ -172,39 +170,23 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - if discovery_info is None: - config = rewrite_common_legacy_to_modern_conf(hass, config) - unique_id = config.get(CONF_UNIQUE_ID) - async_add_entities( - [ - WeatherTemplate( - hass, - config, - unique_id, - ) - ] - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerWeatherEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + WEATHER_DOMAIN, + config, + StateWeatherEntity, + TriggerWeatherEntity, + async_add_entities, + discovery_info, + {}, ) -class WeatherTemplate(TemplateEntity, WeatherEntity): +class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -213,9 +195,8 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): unique_id: str | None, ) -> None: """Initialize the Template weather.""" - super().__init__(hass, config=config, unique_id=unique_id) + super().__init__(hass, config, unique_id) - name = self._attr_name self._condition_template = config[CONF_CONDITION_TEMPLATE] self._temperature_template = config[CONF_TEMPERATURE_TEMPLATE] self._humidity_template = config[CONF_HUMIDITY_TEMPLATE] @@ -224,6 +205,7 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._wind_speed_template = config.get(CONF_WIND_SPEED_TEMPLATE) self._wind_bearing_template = config.get(CONF_WIND_BEARING_TEMPLATE) self._ozone_template = config.get(CONF_OZONE_TEMPLATE) + self._uv_index_template = config.get(CONF_UV_INDEX_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) @@ -243,8 +225,6 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) - self._condition = None self._temperature = None self._humidity = None @@ -253,6 +233,7 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._wind_speed = None self._wind_bearing = None self._ozone = None + self._uv_index = None self._visibility = None self._wind_gust_speed = None self._cloud_coverage = None @@ -300,6 +281,11 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Return the ozone level.""" return self._ozone + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._uv_index + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -394,6 +380,11 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): "_ozone", self._ozone_template, ) + if self._uv_index_template: + self.add_template_attribute( + "_uv_index", + self._uv_index_template, + ) if self._visibility_template: self.add_template_attribute( "_visibility", @@ -505,6 +496,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone: float | None last_pressure: float | None last_temperature: float | None + last_uv_index: float | None last_visibility: float | None last_wind_bearing: float | str | None last_wind_gust_speed: float | None @@ -526,6 +518,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone=restored["last_ozone"], last_pressure=restored["last_pressure"], last_temperature=restored["last_temperature"], + last_uv_index=restored["last_uv_index"], last_visibility=restored["last_visibility"], last_wind_bearing=restored["last_wind_bearing"], last_wind_gust_speed=restored["last_wind_gust_speed"], @@ -538,6 +531,7 @@ class WeatherExtraStoredData(ExtraStoredData): class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = WEATHER_DOMAIN extra_template_keys = ( CONF_CONDITION_TEMPLATE, @@ -553,6 +547,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): ) -> None: """Initialize.""" super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) @@ -576,6 +571,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): CONF_FORECAST_TWICE_DAILY_TEMPLATE, CONF_OZONE_TEMPLATE, CONF_PRESSURE_TEMPLATE, + CONF_UV_INDEX_TEMPLATE, CONF_VISIBILITY_TEMPLATE, CONF_WIND_BEARING_TEMPLATE, CONF_WIND_GUST_SPEED_TEMPLATE, @@ -606,6 +602,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_UV_INDEX_TEMPLATE] = weather_data.last_uv_index self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing self._rendered[CONF_WIND_GUST_SPEED_TEMPLATE] = ( @@ -653,6 +650,13 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered.get(CONF_OZONE_TEMPLATE), ) + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_UV_INDEX_TEMPLATE) + ) + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -726,6 +730,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_uv_index=self._rendered.get(CONF_UV_INDEX_TEMPLATE), last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index d60e2c5a628..1144fd7a4af 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.3.0", - "Pillow==11.2.1" + "numpy==2.3.2", + "Pillow==11.3.0" ] } diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index ac55a380abb..48eb736ae56 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -226,5 +226,7 @@ class OAuth2FlowHandler( def _is_valid_domain(self, domain: str) -> bool: """Validate domain format.""" # Basic domain validation regex - domain_pattern = re.compile(r"^(?:[a-zA-Z0-9]+\.)+[a-zA-Z0-9-]+$") + domain_pattern = re.compile( + r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$" + ) return bool(domain_pattern.match(domain)) diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index d73234b1fdd..761bbebf7a8 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -14,9 +14,8 @@ CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) -CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d" -AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize" -TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token" +AUTHORIZE_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize" +TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" SCOPES = [ Scope.OPENID, diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 20d2d70b5dc..e3a31a2c0dc 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -247,11 +247,15 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[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") + # Add all time periods together output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: - output[key] += period.get(key, 0) + if key in period: + output[key] += period[key] return output diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 4c92e0bd222..3420ed9f46e 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.0"] + "requirements": ["tesla-fleet-api==1.2.3"] } diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index a9b1cfc4845..a5a6cc18411 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -467,7 +467,7 @@ "name": "Tire pressure rear right" }, "version": { - "name": "version" + "name": "Version" }, "vin": { "name": "Vehicle" diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 49af8c1a08d..af4ce26a0cc 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -97,6 +97,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Create the stream stream: TeslemetryStream | None = None + # Remember each device identifier we create + current_devices: set[tuple[str, str]] = set() + for product in products: if ( "vin" in product @@ -116,6 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - model=api.model, serial_number=vin, ) + current_devices.add((DOMAIN, vin)) # Create stream if required if not stream: @@ -133,7 +137,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) firmware = vehicle_metadata[vin].get("firmware", "Unknown") stream_vehicle = stream.get_vehicle(vin) - poll = product["command_signing"] == "off" + poll = vehicle_metadata[vin].get("polling", False) vehicles.append( TeslemetryVehicleData( @@ -171,6 +175,11 @@ 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 try: @@ -215,11 +224,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites ), - *( - energysite.history_coordinator.async_config_entry_first_refresh() - for energysite in energysites - if energysite.history_coordinator - ), ) # Add energy device models @@ -240,6 +244,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - config_entry_id=entry.entry_id, **energysite.device ) + # Remove devices that are no longer present + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if not any( + identifier in current_devices for identifier in device_entry.identifiers + ): + LOGGER.debug("Removing stale device %s", device_entry.id) + device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=entry.entry_id, + ) + # Setup Platforms entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 439df76c838..5db73c7aa06 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -125,8 +125,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="charge_state_conn_charge_cable", polling=True, polling_value_fn=lambda x: x != "", - streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - lambda value: callback(value is not None and value != "Unknown") + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: callback(None if value is None else value != "Disconnected") ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, @@ -542,7 +542,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index cf1d6157ec1..12772b894b6 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry -from .entity import TeslemetryVehiclePollingEntity +from .entity import TeslemetryVehicleStreamEntity from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData @@ -74,7 +74,7 @@ async def async_setup_entry( ) -class TeslemetryButtonEntity(TeslemetryVehiclePollingEntity, ButtonEntity): +class TeslemetryButtonEntity(TeslemetryVehicleStreamEntity, ButtonEntity): """Base class for Teslemetry buttons.""" api: Vehicle diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 1bc52b23026..000e1b136c8 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -67,7 +67,7 @@ async def async_setup_entry( TeslemetryVehiclePollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) @@ -77,7 +77,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index c31bdc2a34e..eed00ebc64f 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -183,6 +183,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=ENERGY_HISTORY_INTERVAL, ) self.api = api + self.data = {} async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" @@ -194,14 +195,14 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): except TeslaFleetError as e: raise UpdateFailed(e.message) from e + if not data or not isinstance(data.get("time_series"), list): + raise UpdateFailed("Received invalid data") + # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) - for period in data.get("time_series", []): + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) + for period in data["time_series"]: for key in ENERGY_HISTORY_FIELDS: if key in period: - if output[key] is None: - output[key] = period[key] - else: - output[key] += period[key] + output[key] += period[key] return output diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index f6ff71ab0cc..5c86d6e19fe 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -45,7 +45,7 @@ async def async_setup_entry( chain( ( TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ), @@ -53,7 +53,7 @@ async def async_setup_entry( TeslemetryVehiclePollingChargePortEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes ) @@ -63,7 +63,7 @@ async def async_setup_entry( TeslemetryVehiclePollingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -73,7 +73,7 @@ async def async_setup_entry( TeslemetryVehiclePollingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -82,7 +82,8 @@ async def async_setup_entry( ( TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + if vehicle.poll + and vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") ), ) ) diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index eb2c220ebbd..0e1b3edf69a 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -89,7 +89,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in DESCRIPTIONS: - if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or vehicle.firmware < description.streaming_firmware: if description.polling_prefix: entities.append( TeslemetryVehiclePollingDeviceTrackerEntity( diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index edd5d404499..f50f5a75f70 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -773,6 +773,18 @@ }, "time_of_use": { "service": "mdi:clock-time-eight-outline" + }, + "add_charge_schedule": { + "service": "mdi:calendar-plus" + }, + "remove_charge_schedule": { + "service": "mdi:calendar-minus" + }, + "add_precondition_schedule": { + "service": "mdi:hvac-outline" + }, + "remove_precondition_schedule": { + "service": "mdi:hvac-off-outline" } } } diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index fda52357f5c..7e98d6338ba 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -42,7 +42,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) @@ -52,7 +52,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index f58783e04a4..b6aff150a96 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.0", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.3", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index bf1fffed583..9ffc02e4307 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -53,7 +53,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" + if vehicle.poll or vehicle.firmware < "2025.2.6" else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 51eed97227e..6d12aa56470 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -28,7 +28,7 @@ class TeslemetryData: vehicles: list[TeslemetryVehicleData] energysites: list[TeslemetryEnergyData] scopes: list[Scope] - stream: TeslemetryStream + stream: TeslemetryStream | None @dataclass diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index bb9f5b588a0..bccefcaf6cb 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -145,7 +145,7 @@ async def async_setup_entry( description, entry.runtime_data.scopes, ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingNumberEntity( vehicle, description, diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index c24c47feb2e..fec54b75880 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -180,7 +180,7 @@ async def async_setup_entry( TeslemetryVehiclePollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 + if vehicle.poll or vehicle.firmware < "2024.26" or description.streaming_listener is None else TeslemetryStreamingSelectEntity( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b50c9b4d0ce..34ee2d4b8e9 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -45,7 +45,7 @@ from .entity import ( TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) -from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData +from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -1565,7 +1565,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): @@ -1575,7 +1575,7 @@ async def async_setup_entry( for time_description in VEHICLE_TIME_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and vehicle.firmware >= time_description.streaming_firmware ): entities.append( @@ -1617,11 +1617,12 @@ async def async_setup_entry( if energysite.history_coordinator is not None ) - entities.append( - TeslemetryCreditBalanceSensor( - entry.unique_id or entry.entry_id, entry.runtime_data + if entry.runtime_data.stream is not None: + entities.append( + TeslemetryCreditBalanceSensor( + entry.unique_id or entry.entry_id, entry.runtime_data.stream + ) ) - ) async_add_entities(entities) @@ -1840,12 +1841,12 @@ class TeslemetryCreditBalanceSensor(RestoreSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_suggested_display_precision = 0 - def __init__(self, uid: str, data: TeslemetryData) -> None: + def __init__(self, uid: str, stream: TeslemetryStream) -> None: """Initialize common aspects of a Teslemetry entity.""" self._attr_translation_key = "credit_balance" self._attr_unique_id = f"{uid}_credit_balance" - self.stream = data.stream + self.stream = stream async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 246cc097a2a..7a6a7b55c0c 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -22,6 +22,7 @@ ATTR_ID = "id" ATTR_GPS = "gps" ATTR_TYPE = "type" ATTR_VALUE = "value" +ATTR_LOCATION = "location" ATTR_LOCALE = "locale" ATTR_ORDER = "order" ATTR_TIMESTAMP = "timestamp" @@ -36,6 +37,12 @@ ATTR_DEPARTURE_TIME = "departure_time" ATTR_OFF_PEAK_CHARGING_ENABLED = "off_peak_charging_enabled" ATTR_OFF_PEAK_CHARGING_WEEKDAYS = "off_peak_charging_weekdays_only" ATTR_END_OFF_PEAK_TIME = "end_off_peak_time" +ATTR_DAYS_OF_WEEK = "days_of_week" +ATTR_START_TIME = "start_time" +ATTR_END_TIME = "end_time" +ATTR_ONE_TIME = "one_time" +ATTR_NAME = "name" +ATTR_PRECONDITION_TIME = "precondition_time" # Services SERVICE_NAVIGATE_ATTR_GPS_REQUEST = "navigation_gps_request" @@ -44,6 +51,10 @@ SERVICE_SET_SCHEDULED_DEPARTURE = "set_scheduled_departure" SERVICE_VALET_MODE = "valet_mode" SERVICE_SPEED_LIMIT = "speed_limit" SERVICE_TIME_OF_USE = "time_of_use" +SERVICE_ADD_CHARGE_SCHEDULE = "add_charge_schedule" +SERVICE_REMOVE_CHARGE_SCHEDULE = "remove_charge_schedule" +SERVICE_ADD_PRECONDITION_SCHEDULE = "add_precondition_schedule" +SERVICE_REMOVE_PRECONDITION_SCHEDULE = "remove_precondition_schedule" def async_get_device_for_service_call( @@ -315,3 +326,195 @@ def async_setup_services(hass: HomeAssistant) -> None: } ), ) + + async def add_charge_schedule(call: ServiceCall) -> None: + """Configure charging schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + days_of_week = call.data[ATTR_DAYS_OF_WEEK] + # If days_of_week is a list (from select with multiple), convert to comma-separated string + if isinstance(days_of_week, list): + days_of_week = ",".join(days_of_week) + enabled = call.data[ATTR_ENABLE] + + # Optional parameters + location = call.data.get( + ATTR_LOCATION, + { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ) + + # Handle time inputs + start_time = None + if start_time_obj := call.data.get(ATTR_START_TIME): + # Convert time object to minutes since midnight + start_time = start_time_obj.hour * 60 + start_time_obj.minute + + end_time = None + if end_time_obj := call.data.get(ATTR_END_TIME): + # Convert time object to minutes since midnight + end_time = end_time_obj.hour * 60 + end_time_obj.minute + + one_time = call.data.get(ATTR_ONE_TIME) + schedule_id = call.data.get(ATTR_ID) + name = call.data.get(ATTR_NAME) + + await handle_vehicle_command( + vehicle.api.add_charge_schedule( + days_of_week=days_of_week, + enabled=enabled, + lat=location[CONF_LATITUDE], + lon=location[CONF_LONGITUDE], + start_time=start_time, + end_time=end_time, + one_time=one_time, + id=schedule_id, + name=name, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + add_charge_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, + vol.Required(ATTR_ENABLE): cv.boolean, + vol.Optional(ATTR_LOCATION): { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + }, + vol.Optional(ATTR_START_TIME): cv.time, + vol.Optional(ATTR_END_TIME): cv.time, + vol.Optional(ATTR_ONE_TIME): cv.boolean, + vol.Optional(ATTR_ID): cv.positive_int, + vol.Optional(ATTR_NAME): cv.string, + } + ), + ) + + async def remove_charge_schedule(call: ServiceCall) -> None: + """Remove a charging schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + schedule_id = call.data[ATTR_ID] + + await handle_vehicle_command( + vehicle.api.remove_charge_schedule( + id=schedule_id, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_CHARGE_SCHEDULE, + remove_charge_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_ID): cv.positive_int, + } + ), + ) + + async def add_precondition_schedule(call: ServiceCall) -> None: + """Add or modify a precondition schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + days_of_week = call.data[ATTR_DAYS_OF_WEEK] + # If days_of_week is a list (from select with multiple), convert to comma-separated string + if isinstance(days_of_week, list): + days_of_week = ",".join(days_of_week) + enabled = call.data[ATTR_ENABLE] + location = call.data.get( + ATTR_LOCATION, + { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ) + + # Convert time object to minutes since midnight + precondition_time = ( + call.data[ATTR_PRECONDITION_TIME].hour * 60 + + call.data[ATTR_PRECONDITION_TIME].minute + ) + + # Optional parameters + schedule_id = call.data.get(ATTR_ID) + one_time = call.data.get(ATTR_ONE_TIME) + name = call.data.get(ATTR_NAME) + + await handle_vehicle_command( + vehicle.api.add_precondition_schedule( + days_of_week=days_of_week, + enabled=enabled, + lat=location[CONF_LATITUDE], + lon=location[CONF_LONGITUDE], + precondition_time=precondition_time, + id=schedule_id, + one_time=one_time, + name=name, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + add_precondition_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, + vol.Required(ATTR_ENABLE): cv.boolean, + vol.Optional(ATTR_LOCATION): { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + }, + vol.Required(ATTR_PRECONDITION_TIME): cv.time, + vol.Optional(ATTR_ID): cv.positive_int, + vol.Optional(ATTR_ONE_TIME): cv.boolean, + vol.Optional(ATTR_NAME): cv.string, + } + ), + ) + + async def remove_precondition_schedule(call: ServiceCall) -> None: + """Remove a preconditioning schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + schedule_id = call.data[ATTR_ID] + + await handle_vehicle_command( + vehicle.api.remove_precondition_schedule( + id=schedule_id, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, + remove_precondition_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_ID): cv.positive_int, + } + ), + ) diff --git a/homeassistant/components/teslemetry/services.yaml b/homeassistant/components/teslemetry/services.yaml index e98f124dd19..4c941c5d41d 100644 --- a/homeassistant/components/teslemetry/services.yaml +++ b/homeassistant/components/teslemetry/services.yaml @@ -130,3 +130,139 @@ speed_limit: min: 1000 max: 9999 mode: box + +add_charge_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + days_of_week: + required: true + selector: + select: + options: + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + - sunday + multiple: true + translation_key: days_of_week + enable: + required: true + selector: + boolean: + location: + required: false + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false + start_time: + required: false + selector: + time: + end_time: + required: false + selector: + time: + one_time: + required: false + selector: + boolean: + id: + required: false + selector: + number: + min: 1 + mode: box + name: + required: false + selector: + text: + +remove_charge_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + id: + required: true + selector: + number: + min: 1 + mode: box + +add_precondition_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + days_of_week: + required: true + selector: + select: + options: + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + - sunday + multiple: true + translation_key: days_of_week + enable: + required: true + selector: + boolean: + location: + required: false + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false + precondition_time: + required: true + selector: + time: + id: + required: false + selector: + number: + min: 1 + mode: box + one_time: + required: false + selector: + boolean: + name: + required: false + selector: + text: + +remove_precondition_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + id: + required: true + selector: + number: + min: 1 + mode: box diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 57b6053bb48..510e2b45a02 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -3,6 +3,26 @@ "unavailable": "Unavailable", "abort": "Abort", "vehicle": "Vehicle", + "wake_up_failed": "Failed to wake up vehicle: {message}", + "wake_up_timeout": "Timed out trying to wake up vehicle", + "schedule_id": "Schedule ID", + "schedule_id_description": "The ID of the schedule, use an existing ID to modify.", + "days_of_week": "Days of week", + "days_of_week_description": "Select which days this schedule should be enabled on. You can select multiple days.", + "one_time": "One-time", + "one_time_description": "If this is a one-time schedule.", + "location_description": "The approximate location the vehicle must be at to use this schedule. Defaults to Home Assistant's configured location.", + "start_time": "Start time", + "start_time_description": "The time this schedule begins, e.g. 01:05 for 1:05 AM.", + "end_time": "End time", + "end_time_description": "The time this schedule ends, e.g. 01:05 for 1:05 AM.", + "precondition_time": "Precondition time", + "precondition_time_description": "The time the vehicle should complete preconditioning, e.g. 01:05 for 1:05 AM.", + "schedule_name_description": "The name of the schedule.", + "vehicle_to_schedule": "Vehicle to schedule.", + "vehicle_to_remove_schedule": "Vehicle to remove schedule from.", + "schedule_enable_description": "If this schedule should be considered for execution.", + "schedule_id_remove_description": "The ID of the schedule to remove.", "descr_pin": "4-digit code to enable or disable the setting" }, "config": { @@ -192,7 +212,7 @@ "name": "European vehicle" }, "right_hand_drive": { - "name": "Right hand drive" + "name": "Right-hand drive" }, "located_at_home": { "name": "Located at home" @@ -1079,15 +1099,6 @@ "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature" }, - "set_scheduled_charging_time": { - "message": "Time required to complete the operation" - }, - "set_scheduled_departure_preconditioning": { - "message": "Departure time required to enable preconditioning" - }, - "set_scheduled_departure_off_peak": { - "message": "To enable scheduled departure, 'End off-peak time' is required." - }, "invalid_device": { "message": "Invalid device ID: {device_id}" }, @@ -1145,7 +1156,7 @@ "description": "Sets a time at which charging should be started.", "fields": { "device_id": { - "description": "Vehicle to schedule.", + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { @@ -1167,7 +1178,7 @@ "name": "Departure time" }, "device_id": { - "description": "Vehicle to schedule.", + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { @@ -1246,6 +1257,127 @@ } }, "name": "Set valet mode" + }, + "add_charge_schedule": { + "description": "Adds or modifies a charging schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "days_of_week": { + "description": "[%key:component::teslemetry::common::days_of_week_description%]", + "name": "[%key:component::teslemetry::common::days_of_week%]" + }, + "enable": { + "description": "[%key:component::teslemetry::common::schedule_enable_description%]", + "name": "[%key:common::action::enable%]" + }, + "location": { + "description": "[%key:component::teslemetry::common::location_description%]", + "name": "Location" + }, + "start_time": { + "description": "[%key:component::teslemetry::common::start_time_description%]", + "name": "[%key:component::teslemetry::common::start_time%]" + }, + "end_time": { + "description": "[%key:component::teslemetry::common::end_time_description%]", + "name": "[%key:component::teslemetry::common::end_time%]" + }, + "one_time": { + "description": "[%key:component::teslemetry::common::one_time_description%]", + "name": "[%key:component::teslemetry::common::one_time%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + }, + "name": { + "description": "[%key:component::teslemetry::common::schedule_name_description%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, + "name": "Add charge schedule" + }, + "remove_charge_schedule": { + "description": "Removes a charging schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_remove_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_remove_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + } + }, + "name": "Remove charge schedule" + }, + "add_precondition_schedule": { + "description": "Adds or modifies a preconditioning schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "days_of_week": { + "description": "[%key:component::teslemetry::common::days_of_week_description%]", + "name": "[%key:component::teslemetry::common::days_of_week%]" + }, + "enable": { + "description": "[%key:component::teslemetry::common::schedule_enable_description%]", + "name": "[%key:common::action::enable%]" + }, + "location": { + "description": "[%key:component::teslemetry::common::location_description%]", + "name": "Location" + }, + "precondition_time": { + "description": "[%key:component::teslemetry::common::precondition_time_description%]", + "name": "[%key:component::teslemetry::common::precondition_time%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + }, + "one_time": { + "description": "[%key:component::teslemetry::common::one_time_description%]", + "name": "[%key:component::teslemetry::common::one_time%]" + }, + "name": { + "description": "[%key:component::teslemetry::common::schedule_name_description%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, + "name": "Add precondition schedule" + }, + "remove_precondition_schedule": { + "description": "Removes a preconditioning schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_remove_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_remove_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + } + }, + "name": "Remove precondition schedule" + } + }, + "selector": { + "days_of_week": { + "options": { + "monday": "[%key:common::time::monday%]", + "tuesday": "[%key:common::time::tuesday%]", + "wednesday": "[%key:common::time::wednesday%]", + "thursday": "[%key:common::time::thursday%]", + "friday": "[%key:common::time::friday%]", + "saturday": "[%key:common::time::saturday%]", + "sunday": "[%key:common::time::sunday%]" + } } } } diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index f607429be46..aae973cf315 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -147,8 +147,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 - or vehicle.firmware < description.streaming_firmware + if vehicle.poll or vehicle.firmware < description.streaming_firmware else TeslemetryStreamingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 144a97039fc..7e0b727ba79 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -39,7 +39,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index c0cbc2ea431..e2ebf64f241 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.0"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.3"] } diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index e9af673b1f4..cd3c3b32857 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -88,6 +88,13 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): return self.get("vehicle_state_software_update_install_perc") return None + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + if self.latest_version is None: + return None + return f"https://stats.tessie.com/versions/{self.latest_version}" + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 29dadfd3d63..6749a53b7b6 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.13.0"] + "requirements": ["thermopro-ble==0.13.1"] } diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 5789de410b2..77722b6e986 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -21,7 +21,7 @@ "entity": { "button": { "set_datetime": { - "name": "Set Date&Time" + "name": "Set date & time" } } } diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index d4e47c31dd2..4bd4c6e81f7 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { "Amazon": "amazon", + "Apple": "apple", "Apple Inc.": "apple", "Aqara": "aqara_gateway", "eero": "eero", diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 9460a50db80..56d51f4f1e0 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -1,5 +1,7 @@ """The threshold component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,12 +9,18 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Min/Max from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -25,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_ENTITY_ID: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_ENTITY_ID] ), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the threshold config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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 + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3227f030812..88fd2784f96 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -31,8 +31,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -102,11 +101,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_ENTITY_ID] ) - device_info = async_device_info_to_link_from_entity( - hass, - entity_id, - ) - hysteresis = config_entry.options[CONF_HYSTERESIS] lower = config_entry.options[CONF_LOWER] name = config_entry.title @@ -116,14 +110,14 @@ async def async_setup_entry( async_add_entities( [ ThresholdSensor( - entity_id, - name, - lower, - upper, - hysteresis, - device_class, - unique_id, - device_info=device_info, + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=unique_id, ) ] ) @@ -146,7 +140,14 @@ async def async_setup_platform( async_add_entities( [ ThresholdSensor( - entity_id, name, lower, upper, hysteresis, device_class, None + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=None, ) ], ) @@ -171,6 +172,8 @@ class ThresholdSensor(BinarySensorEntity): def __init__( self, + hass: HomeAssistant, + *, entity_id: str, name: str, lower: float | None, @@ -178,12 +181,15 @@ class ThresholdSensor(BinarySensorEntity): hysteresis: float, device_class: BinarySensorDeviceClass | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the Threshold sensor.""" self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None self._attr_unique_id = unique_id - self._attr_device_info = device_info + if entity_id: # Guard against empty entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) self._entity_id = entity_id self._attr_name = name if lower is not None: diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index 24f58333782..29f4a0986c1 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -80,6 +80,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Threshold.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -131,13 +133,14 @@ def ws_start_preview( ) preview_entity = ThresholdSensor( - entity_id, - name, - msg["user_input"].get(CONF_LOWER), - msg["user_input"].get(CONF_UPPER), - msg["user_input"].get(CONF_HYSTERESIS), - None, - None, + hass, + entity_id=entity_id, + name=name, + lower=msg["user_input"].get(CONF_LOWER), + upper=msg["user_input"].get(CONF_UPPER), + hysteresis=msg["user_input"].get(CONF_HYSTERESIS), + device_class=None, + unique_id=None, ) preview_entity.hass = hass diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 43cbd79afef..db08f422500 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.31.2"] + "requirements": ["pyTibber==0.31.6"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 26b8f5400a0..1c56d5b2ce6 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -280,7 +280,7 @@ async def async_setup_entry( except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady from err - except aiohttp.ClientError as err: + except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err: _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err @@ -299,7 +299,10 @@ async def async_setup_entry( ) await home.rt_subscribe( TibberRtDataCoordinator( - entity_creator.add_sensors, home, hass + hass, + entry, + entity_creator.add_sensors, + home, ).async_set_updated_data ) @@ -613,15 +616,17 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en def __init__( self, + hass: HomeAssistant, + config_entry: ConfigEntry, add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None], tibber_home: tibber.TibberHome, - hass: HomeAssistant, ) -> None: """Initialize the data handler.""" self._add_sensor_callback = add_sensor_callback super().__init__( hass, _LOGGER, + config_entry=config_entry, name=tibber_home.info["viewer"]["home"]["address"].get( "address1", "Tibber" ), diff --git a/homeassistant/components/tilt_ble/manifest.json b/homeassistant/components/tilt_ble/manifest.json index e22c9d5a1d5..1b178cdb2a6 100644 --- a/homeassistant/components/tilt_ble/manifest.json +++ b/homeassistant/components/tilt_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/tilt_ble", "iot_class": "local_push", - "requirements": ["tilt-ble==0.2.3"] + "requirements": ["tilt-ble==0.3.1"] } diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 9ae98992acb..364bf26d1aa 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import timedelta import logging from typing import Any @@ -12,7 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -24,7 +23,6 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) -from homeassistant.setup import async_prepare_setup_platform from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES from .sensor import TimeDateSensor @@ -99,18 +97,9 @@ async def ws_start_preview( """Generate a preview.""" validated = USER_SCHEMA(msg["user_input"]) - # Create an EntityPlatform, needed for name translations - platform = await async_prepare_setup_platform(hass, {}, SENSOR_DOMAIN, DOMAIN) - entity_platform = EntityPlatform( - hass=hass, - logger=_LOGGER, - domain=SENSOR_DOMAIN, - platform_name=DOMAIN, - platform=platform, - scan_interval=timedelta(seconds=3600), - entity_namespace=None, - ) - await entity_platform.async_load_translations() + # Create PlatformData, needed for name translations + platform_data = PlatformData(hass=hass, domain=SENSOR_DOMAIN, platform_name=DOMAIN) + await platform_data.async_load_translations() @callback def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: @@ -123,7 +112,7 @@ async def ws_start_preview( preview_entity = TimeDateSensor(validated[CONF_DISPLAY_OPTIONS]) preview_entity.hass = hass - preview_entity.platform = entity_platform + preview_entity.platform_data = platform_data connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py new file mode 100644 index 00000000000..696b7395f1e --- /dev/null +++ b/homeassistant/components/togrill/__init__.py @@ -0,0 +1,33 @@ +"""The ToGrill integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator + +_PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.NUMBER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Set up ToGrill Bluetooth from a config entry.""" + + coordinator = ToGrillCoordinator(hass, entry) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as exc: + if not isinstance(exc.__cause__, DeviceNotFound): + raise + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/togrill/config_flow.py b/homeassistant/components/togrill/config_flow.py new file mode 100644 index 00000000000..29d930e7961 --- /dev/null +++ b/homeassistant/components/togrill/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for the ToGrill integration.""" + +from __future__ import annotations + +from typing import Any + +from bleak.exc import BleakError +from togrill_bluetooth import SUPPORTED_DEVICES +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import PacketA0Notify +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, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow + +from .const import CONF_PROBE_COUNT, DOMAIN +from .coordinator import LOGGER + +_TIMEOUT = 10 + + +async def read_config_data( + hass: HomeAssistant, info: BluetoothServiceInfoBleak +) -> dict[str, Any]: + """Read config from device.""" + + try: + client = await Client.connect(info.device) + except BleakError as exc: + LOGGER.debug("Failed to connect", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + + try: + packet_a0 = await client.read(PacketA0Notify) + except BleakError as exc: + LOGGER.debug("Failed to read data", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + finally: + await client.disconnect() + + return { + CONF_MODEL: info.name, + CONF_ADDRESS: info.address, + CONF_PROBE_COUNT: packet_a0.probe_count, + } + + +class ToGrillBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ToGrillBluetooth.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovery_infos: dict[str, BluetoothServiceInfoBleak] = {} + + async def _async_create_entry_internal( + self, info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + config_data = await read_config_data(self.hass, info) + + return self.async_create_entry( + title=config_data[CONF_MODEL], + data=config_data, + ) + + 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() + + if discovery_info.name not in SUPPORTED_DEVICES: + return self.async_abort(reason="not_supported") + + self._discovery_info = discovery_info + 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 + discovery_info = self._discovery_info + + if user_input is not None: + return await self._async_create_entry_internal(discovery_info) + + self._set_confirm_only() + placeholders = {"name": discovery_info.name} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + 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() + + return await self._async_create_entry_internal( + self._discovery_infos[address] + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, True): + address = discovery_info.address + if ( + address in current_addresses + or address in self._discovery_infos + or discovery_info.name not in SUPPORTED_DEVICES + ): + continue + self._discovery_infos[address] = discovery_info + + if not self._discovery_infos: + return self.async_abort(reason="no_devices_found") + + addresses = {info.address: info.name for info in self._discovery_infos.values()} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}), + ) diff --git a/homeassistant/components/togrill/const.py b/homeassistant/components/togrill/const.py new file mode 100644 index 00000000000..dd2fe820919 --- /dev/null +++ b/homeassistant/components/togrill/const.py @@ -0,0 +1,8 @@ +"""Constants for the ToGrill integration.""" + +DOMAIN = "togrill" + +MAX_PROBE_COUNT = 6 + +CONF_PROBE_COUNT = "probe_count" +CONF_VERSION = "version" diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py new file mode 100644 index 00000000000..75964067de7 --- /dev/null +++ b/homeassistant/components/togrill/coordinator.py @@ -0,0 +1,196 @@ +"""Coordinator for the ToGrill Bluetooth integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +import logging +from typing import TypeVar + +from bleak.exc import BleakError +from togrill_bluetooth.client import Client +from togrill_bluetooth.exceptions import DecodeError +from togrill_bluetooth.packets import ( + Packet, + PacketA0Notify, + PacketA1Notify, + PacketA8Write, +) + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothCallbackMatcher, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + async_register_callback, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_MODEL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_PROBE_COUNT + +type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator] + +SCAN_INTERVAL = timedelta(seconds=30) +LOGGER = logging.getLogger(__name__) + +PacketType = TypeVar("PacketType", bound=Packet) + + +def get_version_string(packet: PacketA0Notify) -> str: + """Construct a version string from packet data.""" + return f"{packet.version_major}.{packet.version_minor}" + + +class DeviceNotFound(UpdateFailed): + """Update failed due to device disconnected.""" + + +class DeviceFailed(UpdateFailed): + """Update failed due to device disconnected.""" + + +class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Packet]]): + """Class to manage fetching data.""" + + config_entry: ToGrillConfigEntry + client: Client | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: ToGrillConfigEntry, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass=hass, + logger=LOGGER, + config_entry=config_entry, + name="ToGrill", + update_interval=SCAN_INTERVAL, + ) + self.address = config_entry.data[CONF_ADDRESS] + self.data = {} + self.device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, self.address)} + ) + self._packet_listeners: list[Callable[[Packet], None]] = [] + + config_entry.async_on_unload( + async_register_callback( + hass, + self._async_handle_bluetooth_event, + BluetoothCallbackMatcher(address=self.address, connectable=True), + BluetoothScanningMode.ACTIVE, + ) + ) + + @callback + def async_add_packet_listener( + self, packet_callback: Callable[[Packet], None] + ) -> Callable[[], None]: + """Add a listener for a given packet type.""" + + def _unregister(): + self._packet_listeners.remove(packet_callback) + + self._packet_listeners.append(packet_callback) + return _unregister + + def async_update_packet_listeners(self, packet: Packet): + """Update all packet listeners.""" + for listener in self._packet_listeners: + listener(packet) + + async def _connect_and_update_registry(self) -> Client: + """Update device registry data.""" + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True + ) + if not device: + raise DeviceNotFound("Unable to find device") + + try: + client = await Client.connect(device, self._notify_callback) + except BleakError as exc: + self.logger.debug("Connection failed", exc_info=True) + raise DeviceNotFound("Unable to connect to device") from exc + + try: + packet_a0 = await client.read(PacketA0Notify) + except (BleakError, DecodeError) as exc: + await client.disconnect() + raise DeviceFailed(f"Device failed {exc}") from exc + + config_entry = self.config_entry + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_BLUETOOTH, self.address)}, + name=config_entry.data[CONF_MODEL], + model_id=config_entry.data[CONF_MODEL], + sw_version=get_version_string(packet_a0), + ) + + return client + + async def async_shutdown(self) -> None: + """Shutdown coordinator and disconnect from device.""" + await super().async_shutdown() + if self.client: + await self.client.disconnect() + self.client = None + + async def _get_connected_client(self) -> Client: + if self.client and not self.client.is_connected: + await self.client.disconnect() + self.client = None + if self.client: + return self.client + + self.client = await self._connect_and_update_registry() + return self.client + + def get_packet( + self, packet_type: type[PacketType], probe=None + ) -> PacketType | None: + """Get a cached packet of a certain type.""" + + if packet := self.data.get((packet_type.type, probe)): + assert isinstance(packet, packet_type) + return packet + return None + + def _notify_callback(self, packet: Packet): + probe = getattr(packet, "probe", None) + self.data[(packet.type, probe)] = packet + self.async_update_packet_listeners(packet) + self.async_update_listeners() + + async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: + """Poll the device.""" + client = await self._get_connected_client() + try: + await client.request(PacketA0Notify) + await client.request(PacketA1Notify) + for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1): + await client.write(PacketA8Write(probe=probe)) + except BleakError as exc: + raise DeviceFailed(f"Device failed {exc}") from exc + return self.data + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + if not self.client and isinstance(self.last_exception, DeviceNotFound): + self.hass.async_create_task(self.async_refresh()) diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py new file mode 100644 index 00000000000..7d956ac2d57 --- /dev/null +++ b/homeassistant/components/togrill/entity.py @@ -0,0 +1,49 @@ +"""Provides the base entities.""" + +from __future__ import annotations + +from bleak.exc import BleakError +from togrill_bluetooth.client import Client +from togrill_bluetooth.exceptions import BaseError +from togrill_bluetooth.packets import PacketWrite + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LOGGER, ToGrillCoordinator + + +class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): + """Coordinator entity for Gardena Bluetooth.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ToGrillCoordinator) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + + def _get_client(self) -> Client: + client = self.coordinator.client + if client is None or not client.is_connected: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="disconnected" + ) + return client + + async def _write_packet(self, packet: PacketWrite) -> None: + client = self._get_client() + try: + await client.write(packet) + except BleakError as exc: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="communication_failed" + ) from exc + except BaseError as exc: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="rejected" + ) from exc + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/togrill/event.py b/homeassistant/components/togrill/event.py new file mode 100644 index 00000000000..eb0cc502080 --- /dev/null +++ b/homeassistant/components/togrill/event.py @@ -0,0 +1,59 @@ +"""Support for event entities.""" + +from __future__ import annotations + +from togrill_bluetooth.packets import Packet, PacketA5Notify + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import slugify + +from . import ToGrillConfigEntry +from .const import CONF_PROBE_COUNT +from .coordinator import ToGrillCoordinator +from .entity import ToGrillEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up event platform.""" + async_add_entities( + ToGrillEventEntity(config_entry.runtime_data, probe_number=probe_number) + for probe_number in range(1, config_entry.data[CONF_PROBE_COUNT] + 1) + ) + + +class ToGrillEventEntity(ToGrillEntity, EventEntity): + """Representation of a Hue Event entity from a button resource.""" + + def __init__(self, coordinator: ToGrillCoordinator, probe_number: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator=coordinator) + + self._attr_translation_key = "event" + self._attr_translation_placeholders = {"probe_number": f"{probe_number}"} + self._attr_unique_id = f"{coordinator.address}_{probe_number}" + self._probe_number = probe_number + + self._attr_event_types: list[str] = [ + slugify(event.name) for event in PacketA5Notify.Message + ] + + self.async_on_remove(coordinator.async_add_packet_listener(self._handle_event)) + + @callback + def _handle_event(self, packet: Packet) -> None: + if not isinstance(packet, PacketA5Notify): + return + + try: + message = PacketA5Notify.Message(packet.message) + except ValueError: + return + self._trigger_event(slugify(message.name)) diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json new file mode 100644 index 00000000000..4b833aec4ee --- /dev/null +++ b/homeassistant/components/togrill/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "togrill", + "name": "ToGrill", + "bluetooth": [ + { + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + "connectable": true + } + ], + "codeowners": ["@elupus"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://www.home-assistant.io/integrations/togrill", + "iot_class": "local_push", + "loggers": ["togrill_bluetooth"], + "quality_scale": "bronze", + "requirements": ["togrill-bluetooth==0.7.0"] +} diff --git a/homeassistant/components/togrill/number.py b/homeassistant/components/togrill/number.py new file mode 100644 index 00000000000..a87fec8d2d3 --- /dev/null +++ b/homeassistant/components/togrill/number.py @@ -0,0 +1,138 @@ +"""Support for number entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from togrill_bluetooth.packets import ( + PacketA0Notify, + PacketA6Write, + PacketA8Notify, + PacketA301Write, + PacketWrite, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ToGrillConfigEntry +from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT +from .coordinator import ToGrillCoordinator +from .entity import ToGrillEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class ToGrillNumberEntityDescription(NumberEntityDescription): + """Description of entity.""" + + get_value: Callable[[ToGrillCoordinator], float | None] + set_packet: Callable[[float], PacketWrite] + entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True + + +def _get_temperature_target_description( + probe_number: int, +) -> ToGrillNumberEntityDescription: + def _set_packet(value: float | None) -> PacketWrite: + if value == 0.0: + value = None + return PacketA301Write(probe=probe_number, target=value) + + def _get_value(coordinator: ToGrillCoordinator) -> float | None: + if packet := coordinator.get_packet(PacketA8Notify, probe_number): + if packet.alarm_type == PacketA8Notify.AlarmType.TEMPERATURE_TARGET: + return packet.temperature_1 + return None + + return ToGrillNumberEntityDescription( + key=f"temperature_target_{probe_number}", + translation_key="temperature_target", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=0, + native_max_value=250, + mode=NumberMode.BOX, + set_packet=_set_packet, + get_value=_get_value, + entity_supported=lambda x: probe_number <= x[CONF_PROBE_COUNT], + ) + + +ENTITY_DESCRIPTIONS = ( + *[ + _get_temperature_target_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], + ToGrillNumberEntityDescription( + key="alarm_interval", + translation_key="alarm_interval", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_min_value=0, + native_max_value=15, + native_step=5, + mode=NumberMode.BOX, + set_packet=lambda x: ( + PacketA6Write(temperature_unit=None, alarm_interval=round(x)) + ), + get_value=lambda x: ( + packet.alarm_interval if (packet := x.get_packet(PacketA0Notify)) else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillNumber(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + if entity_description.entity_supported(entry.data) + ) + + +class ToGrillNumber(ToGrillEntity, NumberEntity): + """Representation of a number.""" + + entity_description: ToGrillNumberEntityDescription + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ToGrillNumberEntityDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @property + def native_value(self) -> float | None: + """Return the value reported by the number.""" + return self.entity_description.get_value(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set value on device.""" + + packet = self.entity_description.set_packet(value) + await self._write_packet(packet) diff --git a/homeassistant/components/togrill/quality_scale.yaml b/homeassistant/components/togrill/quality_scale.yaml new file mode 100644 index 00000000000..6dd44090f80 --- /dev/null +++ b/homeassistant/components/togrill/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration only has a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration only has a single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not need any websession + strict-typing: todo diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py new file mode 100644 index 00000000000..1641236bfc1 --- /dev/null +++ b/homeassistant/components/togrill/sensor.py @@ -0,0 +1,129 @@ +"""Support for sensor entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, cast + +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ToGrillConfigEntry +from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT +from .coordinator import ToGrillCoordinator +from .entity import ToGrillEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class ToGrillSensorEntityDescription(SensorEntityDescription): + """Description of entity.""" + + packet_type: int + packet_extract: Callable[[Packet], StateType] + entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True + + +def _get_temperature_description(probe_number: int): + def _get(packet: Packet) -> StateType: + assert isinstance(packet, PacketA1Notify) + if len(packet.temperatures) < probe_number: + return None + temperature = packet.temperatures[probe_number - 1] + if temperature is None: + return None + return temperature + + def _supported(config: Mapping[str, Any]): + return probe_number <= config[CONF_PROBE_COUNT] + + return ToGrillSensorEntityDescription( + key=f"temperature_{probe_number}", + translation_key="temperature", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + packet_type=PacketA1Notify.type, + packet_extract=_get, + entity_supported=_supported, + ) + + +ENTITY_DESCRIPTIONS = ( + ToGrillSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + packet_type=PacketA0Notify.type, + packet_extract=lambda packet: cast(PacketA0Notify, packet).battery, + ), + *[ + _get_temperature_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillSensor(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + if entity_description.entity_supported(entry.data) + ) + + +class ToGrillSensor(ToGrillEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: ToGrillSensorEntityDescription + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ToGrillSensorEntityDescription, + ) -> None: + """Initialize sensor.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.native_value is not None + + @property + def native_value(self) -> StateType: + """Get current value.""" + if packet := self.coordinator.data.get( + (self.entity_description.packet_type, None) + ): + return self.entity_description.packet_extract(packet) + return None diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json new file mode 100644 index 00000000000..309ac65f54c --- /dev/null +++ b/homeassistant/components/togrill/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select the device to add." + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "failed_to_read_config": "Failed to read config from device" + } + }, + "exceptions": { + "disconnected": { + "message": "The device is disconnected" + }, + "communication_failed": { + "message": "Communication failed with the device" + }, + "rejected": { + "message": "Data was rejected by device" + } + }, + "entity": { + "sensor": { + "temperature": { + "name": "Probe {probe_number}" + } + }, + "number": { + "temperature_target": { + "name": "Target {probe_number}" + }, + "alarm_interval": { + "name": "Alarm interval" + } + }, + "event": { + "event": { + "name": "Probe {probe_number}", + "state_attributes": { + "event_type": { + "state": { + "probe_acknowledge": "Alarm acknowledged", + "probe_alarm": "Alarm triggered", + "probe_disconnected": "Probe disconnected" + } + } + } + } + } + } +} diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 08e1991d831..f288f011061 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -197,7 +197,7 @@ SENSOR_TYPES = ( attribute=TMRW_ATTR_PRECIPITATION_TYPE, value_map=PrecipitationType, ), - # Data comes in as ppb, convert to µg/m^3 + # Data comes in as ppb, convert to μg/m^3 # Molecular weight of Ozone is 48 TomorrowioSensorEntityDescription( key="ozone", @@ -221,7 +221,7 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, ), - # Data comes in as ppb, convert to µg/m^3 + # Data comes in as ppb, convert to μg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 TomorrowioSensorEntityDescription( key="nitrogen_dioxide", @@ -240,7 +240,7 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, ), - # Data comes in as ppb, convert to µg/m^3 + # Data comes in as ppb, convert to μg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 TomorrowioSensorEntityDescription( key="sulphur_dioxide", diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index c3f52155d29..033b338f1a4 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -23,10 +23,10 @@ "options": { "step": { "init": { - "title": "Update Tomorrow.io Options", + "title": "Update Tomorrow.io options", "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", "data": { - "timestep": "Min. Between NowCast Forecasts" + "timestep": "Minutes between NowCast forecasts" } } } diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 2f3802dc9a6..7cc8d7a5ebc 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -172,9 +172,9 @@ class TotalConnectZoneBinarySensor(TotalConnectZoneEntity, BinarySensorEntity): super().__init__(coordinator, zone, location_id, entity_description.key) self.entity_description = entity_description self._attr_extra_state_attributes = { - "zone_id": zone.zoneid, + "zone_id": str(zone.zoneid), "location_id": location_id, - "partition": zone.partition, + "partition": str(zone.partition), } @property diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 3f5d05fda13..33e82dcaf53 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -105,11 +105,7 @@ class TotalConnectConfigFlow(ConfigFlow, domain=DOMAIN): }, ) else: - # Force the loading of locations using I/O - number_locations = await self.hass.async_add_executor_job( - self.client.get_number_locations, - ) - if number_locations < 1: + if self.client.get_number_locations() < 1: return self.async_abort(reason="no_locations") for location_id in self.client.locations: self.usercodes[location_id] = None diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 6aff1ea392b..cd349cd3414 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2025.1.4"] + "requirements": ["total-connect-client==2025.5"] } diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index ab07ae770fd..5140584f7ff 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.3.0"] + "requirements": ["pytouchlinesl==0.4.0"] } diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index a7f9dfbcb09..70eff4a34c4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -30,8 +30,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "Your TP-Link cloud username which is the full email and is case sensitive.", - "password": "Your TP-Link cloud password which is case sensitive." + "username": "Your TP-Link cloud username which is the full email and is case-sensitive.", + "password": "Your TP-Link cloud password which is case-sensitive." } }, "discovery_auth_confirm": { diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py index b186424d32c..ae2f01e698b 100644 --- a/homeassistant/components/traccar_server/config_flow.py +++ b/homeassistant/components/traccar_server/config_flow.py @@ -2,9 +2,15 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any -from pytraccar import ApiClient, ServerModel, TraccarException +from pytraccar import ( + ApiClient, + ServerModel, + TraccarAuthenticationException, + TraccarException, +) import voluptuous as vol from homeassistant import config_entries @@ -160,6 +166,65 @@ class TraccarServerConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth( + self, _entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle reauth flow.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + test_data = { + **reauth_entry.data, + **user_input, + } + try: + await self._get_server_info(test_data) + except TraccarAuthenticationException: + LOGGER.error("Invalid credentials for Traccar Server") + errors["base"] = "invalid_auth" + except TraccarException as exception: + LOGGER.error("Unable to connect to Traccar Server: %s", exception) + 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=user_input, + ) + username = ( + user_input[CONF_USERNAME] + if user_input + else reauth_entry.data[CONF_USERNAME] + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=username): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + description_placeholders={ + CONF_HOST: reauth_entry.data[CONF_HOST], + CONF_PORT: reauth_entry.data[CONF_PORT], + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 2c878856cc2..9cb0530356f 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -13,11 +13,13 @@ from pytraccar import ( GeofenceModel, PositionModel, SubscriptionData, + TraccarAuthenticationException, TraccarException, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -31,7 +33,7 @@ from .const import ( EVENTS, LOGGER, ) -from .helpers import get_device, get_first_geofence +from .helpers import get_device, get_first_geofence, get_geofence_ids class TraccarServerCoordinatorDataDevice(TypedDict): @@ -90,6 +92,8 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat self.client.get_positions(), self.client.get_geofences(), ) + except TraccarAuthenticationException: + raise ConfigEntryAuthFailed from None except TraccarException as ex: raise UpdateFailed(f"Error while updating device data: {ex}") from ex @@ -131,7 +135,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat "device": device, "geofence": get_first_geofence( geofences, - position["geofenceIds"] or [], + get_geofence_ids(device, position), ), "position": position, "attributes": attr, @@ -187,7 +191,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat self.data[device_id]["attributes"] = attr self.data[device_id]["geofence"] = get_first_geofence( self._geofences, - position["geofenceIds"] or [], + get_geofence_ids(self.data[device_id]["device"], position), ) update_devices.add(device_id) @@ -236,6 +240,8 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat """Subscribe to events.""" try: await self.client.subscribe(self.handle_subscription_data) + except TraccarAuthenticationException: + raise ConfigEntryAuthFailed from None except TraccarException as ex: if self._should_log_subscription_error: self._should_log_subscription_error = False diff --git a/homeassistant/components/traccar_server/helpers.py b/homeassistant/components/traccar_server/helpers.py index 971f51376b8..9a22f2784bc 100644 --- a/homeassistant/components/traccar_server/helpers.py +++ b/homeassistant/components/traccar_server/helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pytraccar import DeviceModel, GeofenceModel +from pytraccar import DeviceModel, GeofenceModel, PositionModel def get_device(device_id: int, devices: list[DeviceModel]) -> DeviceModel | None: @@ -22,3 +22,17 @@ def get_first_geofence( (geofence for geofence in geofences if geofence["id"] in target), None, ) + + +def get_geofence_ids( + device: DeviceModel, + position: PositionModel, +) -> list[int]: + """Compatibility helper to return a list of geofence IDs.""" + # For Traccar >=5.8 https://github.com/traccar/traccar/commit/30bafaed42e74863c5ca68a33c87f39d1e2de93d + if "geofenceIds" in position: + return position["geofenceIds"] or [] + # For Traccar <5.8 + if "geofenceIds" in device: + return device["geofenceIds"] or [] + return [] diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json index 5fac2f108f7..18c30e52233 100644 --- a/homeassistant/components/traccar_server/manifest.json +++ b/homeassistant/components/traccar_server/manifest.json @@ -1,7 +1,7 @@ { "domain": "traccar_server", "name": "Traccar Server", - "codeowners": ["@ludeeus"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/traccar_server", "iot_class": "local_push", diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index a4b57562388..89b7b180346 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -14,14 +14,23 @@ "host": "The hostname or IP address of your Traccar Server", "username": "The username (email) you use to log in to your Traccar Server" } + }, + "reauth_confirm": { + "description": "The authentication credentials for {host}:{port} need to be updated.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "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%]" } }, "options": { diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 19f88817e71..7cdb0c02f5b 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> b ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -53,11 +52,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: TVTrainConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index fb39e14815e..2328a7126fd 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback @@ -329,7 +329,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): ) -class TVTrainOptionsFlowHandler(OptionsFlow): +class TVTrainOptionsFlowHandler(OptionsFlowWithReload): """Handle Trafikverket Train options.""" async def async_step_init( diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index afe2660e711..458f719e5f2 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -60,12 +60,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): @property def limit(self) -> int: """Return limit.""" - return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) + return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) # type: ignore[no-any-return] @property def order(self) -> str: """Return order.""" - return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) + return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return] async def _async_update_data(self) -> SessionStats: """Update transmission data.""" diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index 086ac818c8e..332ec9455eb 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) PLATFORMS = [Platform.BINARY_SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trend from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -37,6 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -53,6 +62,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the trend config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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 + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle an Trend options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 30058bb056c..5a7046c2125 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -33,8 +33,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -114,6 +114,7 @@ async def async_setup_platform( for sensor_name, sensor_config in config[CONF_SENSORS].items(): entities.append( SensorTrend( + hass, name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name), entity_id=sensor_config[CONF_ENTITY_ID], attribute=sensor_config.get(CONF_ATTRIBUTE), @@ -140,14 +141,10 @@ async def async_setup_entry( ) -> None: """Set up trend sensor from config entry.""" - device_info = async_device_info_to_link_from_entity( - hass, - entry.options[CONF_ENTITY_ID], - ) - async_add_entities( [ SensorTrend( + hass, name=entry.title, entity_id=entry.options[CONF_ENTITY_ID], attribute=entry.options.get(CONF_ATTRIBUTE), @@ -159,7 +156,6 @@ async def async_setup_entry( min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES), max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES), unique_id=entry.entry_id, - device_info=device_info, ) ] ) @@ -174,6 +170,8 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, + *, name: str, entity_id: str, attribute: str | None, @@ -185,7 +183,6 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): unique_id: str | None = None, device_class: BinarySensorDeviceClass | None = None, sensor_entity_id: str | None = None, - device_info: dr.DeviceInfo | None = None, ) -> None: """Initialize the sensor.""" self._entity_id = entity_id @@ -199,7 +196,10 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self._attr_name = name self._attr_device_class = device_class self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) if sensor_entity_id: self.entity_id = sensor_entity_id diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py index 756b9536d19..3bb06ae3042 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -101,6 +101,8 @@ CONFIG_SCHEMA = vol.Schema( class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Trend.""" + MINOR_VERSION = 2 + config_flow = { "user": SchemaFlowFormStep(schema=CONFIG_SCHEMA, next_step="settings"), "settings": SchemaFlowFormStep(get_base_options_schema), diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index e35c10a9ece..a6d0f8a0427 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.3.0"] + "requirements": ["numpy==2.3.2"] } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 8292df07ef8..629332d9d64 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -382,7 +382,7 @@ async def _async_convert_audio( assert process.stderr stderr_data = await process.stderr.read() _LOGGER.error(stderr_data.decode()) - raise RuntimeError( + raise HomeAssistantError( f"Unexpected error while running ffmpeg with arguments: {command}. " "See log for details." ) @@ -976,11 +976,15 @@ class SpeechManager: if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider) or isinstance(message_or_stream, str): + if isinstance(engine_instance, Provider) or ( + not engine_instance.async_supports_streaming_input() + ): + # Non-streaming if isinstance(message_or_stream, str): message = message_or_stream else: message = "".join([chunk async for chunk in message_or_stream]) + extension, data = await engine_instance.async_internal_get_tts_audio( message, language, options ) @@ -996,8 +1000,19 @@ class SpeechManager: data_gen = make_data_generator(data) else: + # Streaming + if isinstance(message_or_stream, str): + + async def gen_stream() -> AsyncGenerator[str]: + yield message_or_stream + + stream = gen_stream() + + else: + stream = message_or_stream + tts_result = await engine_instance.internal_async_stream_tts_audio( - TTSAudioRequest(language, options, message_or_stream) + TTSAudioRequest(language, options, stream) ) extension = tts_result.extension data_gen = tts_result.data_gen @@ -1185,6 +1200,21 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.manager = manager + async def head(self, request: web.Request, token: str) -> web.StreamResponse: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the token (file) exists and return its content type. + """ + stream = self.manager.token_to_stream.get(token) + + if stream is None: + return web.Response(status=HTTPStatus.NOT_FOUND) + + return web.Response(content_type=stream.content_type) + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" stream = self.manager.token_to_stream.get(token) diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index dc6f22570fc..77abaa26bab 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -165,18 +165,6 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH self.async_write_ha_state() return await self.async_stream_tts_audio(request) - @final - async def async_internal_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine and update state. - - Return a tuple of file extension and data as bytes. - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio(message, language, options=options) - async def async_stream_tts_audio( self, request: TTSAudioRequest ) -> TTSAudioResponse: @@ -203,6 +191,18 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH """Load tts audio file from the engine.""" raise NotImplementedError + @final + async def async_internal_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine and update state. + + Return a tuple of file extension and data as bytes. + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio(message, language, options=options) + async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 106075e9314..6ed8f0253ab 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -153,11 +153,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool # Register known device IDs device_registry = dr.async_get(hass) for device in manager.device_map.values(): + LOGGER.debug( + "Register device %s: %s (function: %s, status range: %s)", + device.id, + device.status, + device.function, + device.status_range, + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, manufacturer="Tuya", name=device.name, + # Note: the model is overridden via entity.device_info property + # when the entity is created. If no entities are generated, it will + # stay as unsupported model=f"{device.product_name} (unsupported)", model_id=device.product_id, ) @@ -237,6 +247,14 @@ class DeviceListener(SharingDeviceListener): # Ensure the device isn't present stale self.hass.add_job(self.async_remove_device, device.id) + LOGGER.debug( + "Add device %s: %s (function: %s, status range: %s)", + device.id, + device.status, + device.function, + device.status_range, + ) + dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) def remove_device(self, device_id: str) -> None: diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 4972fe88339..d08a3bef7ce 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -20,7 +20,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -139,7 +141,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): self._master_state = enum_type # Determine alarm message - if dp_code := self.find_dpcode(description.alarm_msg, prefer_function=True): + if dp_code := get_dpcode(self.device, description.alarm_msg): self._alarm_msg_dpcode = dp_code @property diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 486dd6e1387..f9bc973f5a1 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -15,9 +15,10 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity @@ -31,6 +32,9 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): # Value or values to consider binary sensor to be "on" on_value: bool | float | int | str | set[bool | float | int | str] = True + # For DPType.BITMAP, the bitmap_key is used to extract the bit mask + bitmap_key: str | None = None + # Commonly used sensors TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( @@ -46,6 +50,68 @@ TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( # end up being a binary sensor. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO2_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="1", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATUS, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + TuyaBinarySensorEntityDescription( + key="tankfull", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="tankfull", + translation_key="tankfull", + ), + TuyaBinarySensorEntityDescription( + key="defrost", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="defrost", + translation_key="defrost", + ), + TuyaBinarySensorEntityDescription( + key="wet", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="wet", + translation_key="wet", + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + TuyaBinarySensorEntityDescription( + key=DPCode.FEED_STATE, + translation_key="feeding", + on_value="feeding", + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -111,40 +177,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - TuyaBinarySensorEntityDescription( - key=DPCode.CO2_STATE, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="alarm", - ), - TAMPER_BINARY_SENSOR, - ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( - TuyaBinarySensorEntityDescription( - key=DPCode.CO_STATE, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="1", - ), - TuyaBinarySensorEntityDescription( - key=DPCode.CO_STATUS, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="alarm", - ), - TAMPER_BINARY_SENSOR, - ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( - TuyaBinarySensorEntityDescription( - key=DPCode.FEED_STATE, - translation_key="feeding", - on_value="feeding", - ), - ), # Human Presence Sensor # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs "hps": ( @@ -174,6 +206,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + TuyaBinarySensorEntityDescription( + key=DPCode.TEMPER_ALARM, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TAMPER_BINARY_SENSOR, + ), # Door and Window Controller # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 "mc": ( @@ -205,16 +247,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { on_value={"AQAB"}, ), ), - # Luminance Sensor - # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 - "ldcg": ( - TuyaBinarySensorEntityDescription( - key=DPCode.TEMPER_ALARM, - device_class=BinarySensorDeviceClass.TAMPER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TAMPER_BINARY_SENSOR, - ), # PIR Detector # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 "pir": ( @@ -235,6 +267,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Gas Detector # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw "rqbj": ( @@ -279,6 +314,25 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Gateway control + # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok + "wg2": ( + TuyaBinarySensorEntityDescription( + key=DPCode.MASTER_STATE, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + on_value="alarm", + ), + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + TuyaBinarySensorEntityDescription( + key=DPCode.VALVE_STATE, + translation_key="valve", + on_value="open", + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( @@ -291,9 +345,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( @@ -343,6 +394,22 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { } +def _get_bitmap_bit_mask( + device: CustomerDevice, dpcode: str, bitmap_key: str | None +) -> int | None: + """Get the bit mask for a given bitmap description.""" + if ( + bitmap_key is None + or (status_range := device.status_range.get(dpcode)) is None + or status_range.type != DPType.BITMAP + or not isinstance(bitmap_values := json_loads(status_range.values), dict) + or not isinstance(bitmap_labels := bitmap_values.get("label"), list) + or bitmap_key not in bitmap_labels + ): + return None + return bitmap_labels.index(bitmap_key) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -361,12 +428,23 @@ async def async_setup_entry( for description in descriptions: dpcode = description.dpcode or description.key if dpcode in device.status: - entities.append( - TuyaBinarySensorEntity( - device, hass_data.manager, description - ) + mask = _get_bitmap_bit_mask( + device, dpcode, description.bitmap_key ) + if ( + description.bitmap_key is None # Regular binary sensor + or mask is not None # Bitmap sensor with valid mask + ): + entities.append( + TuyaBinarySensorEntity( + device, + hass_data.manager, + description, + mask, + ) + ) + async_add_entities(entities) async_discover_device([*hass_data.manager.device_map]) @@ -386,11 +464,13 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): device: CustomerDevice, device_manager: Manager, description: TuyaBinarySensorEntityDescription, + bit_mask: int | None = None, ) -> None: """Init Tuya binary sensor.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" + self._bit_mask = bit_mask @property def is_on(self) -> bool: @@ -399,6 +479,10 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): if dpcode not in self.device.status: return False + if self._bit_mask is not None: + # For bitmap sensors, check the specific bit mask + return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0 + if isinstance(self.entity_description.on_value, set): return self.device.status[dpcode] in self.entity_description.on_value diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 8e538b07309..928e584e77d 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -17,6 +17,14 @@ from .entity import TuyaEntity # All descriptions can be found here. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { + # Wake Up Light II + # Not documented + "hxd": ( + ButtonEntityDescription( + key=DPCode.SWITCH_USB6, + translation_key="snooze", + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -46,14 +54,6 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Wake Up Light II - # Not documented - "hxd": ( - ButtonEntityDescription( - key=DPCode.SWITCH_USB6, - translation_key="snooze", - ), - ), } diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index c04a8a043dc..788a9bcc5c3 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -17,12 +17,12 @@ from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq CAMERAS: tuple[str, ...] = ( - # Smart Camera (including doorbells) - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sp", # Smart Camera - Low power consumption camera # Undocumented, see https://github.com/home-assistant/core/issues/132844 "dghsxj", + # Smart Camera (including doorbells) + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sp", ) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 547f3a14c93..ecfc96f1d67 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from tuya_sharing import CustomerDevice, Manager @@ -25,7 +25,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData +from .util import get_dpcode TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, @@ -47,6 +49,12 @@ class TuyaClimateEntityDescription(ClimateEntityDescription): CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { + # Electric Fireplace + # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + "dbl": TuyaClimateEntityDescription( + key="dbl", + switch_only_hvac_mode=HVACMode.HEAT, + ), # Air conditioner # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n "kt": TuyaClimateEntityDescription( @@ -77,9 +85,6 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { key="wkf", switch_only_hvac_mode=HVACMode.HEAT, ), - # Electric Fireplace - # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop - "dbl": TuyaClimateEntityDescription(key="dbl", switch_only_hvac_mode=HVACMode.HEAT), } @@ -225,7 +230,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): self._attr_hvac_modes.append(description.switch_only_hvac_mode) self._attr_preset_modes = unknown_hvac_modes self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE - elif self.find_dpcode(DPCode.SWITCH, prefer_function=True): + elif get_dpcode(self.device, DPCode.SWITCH): self._attr_hvac_modes = [ HVACMode.OFF, description.switch_only_hvac_mode, @@ -246,33 +251,35 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ) # Determine fan modes + self._fan_mode_dp_code: str | None = None if enum_type := self.find_dpcode( - (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + (DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED), dptype=DPType.ENUM, prefer_function=True, ): self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_fan_modes = enum_type.range + self._fan_mode_dp_code = enum_type.dpcode # Determine swing modes - if self.find_dpcode( + if get_dpcode( + self.device, ( DPCode.SHAKE, DPCode.SWING, DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL, ), - prefer_function=True, ): self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._attr_swing_modes = [SWING_OFF] - if self.find_dpcode((DPCode.SHAKE, DPCode.SWING), prefer_function=True): + if get_dpcode(self.device, (DPCode.SHAKE, DPCode.SWING)): self._attr_swing_modes.append(SWING_ON) - if self.find_dpcode(DPCode.SWITCH_HORIZONTAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_HORIZONTAL): self._attr_swing_modes.append(SWING_HORIZONTAL) - if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_VERTICAL): self._attr_swing_modes.append(SWING_VERTICAL) if DPCode.SWITCH in self.device.function: @@ -300,14 +307,17 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}]) + if TYPE_CHECKING: + # guarded by ClimateEntityFeature.FAN_MODE + assert self._fan_mode_dp_code is not None + + self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self._set_humidity is None: - raise RuntimeError( - "Cannot set humidity, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by ClimateEntityFeature.TARGET_HUMIDITY + assert self._set_humidity is not None self._send_command( [ @@ -345,11 +355,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if self._set_temperature is None: - raise RuntimeError( - "Cannot set target temperature, device doesn't provide methods to" - " set it" - ) + if TYPE_CHECKING: + # guarded by ClimateEntityFeature.TARGET_TEMPERATURE + assert self._set_temperature is not None self._send_command( [ @@ -456,7 +464,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return fan mode.""" - return self.device.status.get(DPCode.FAN_SPEED_ENUM) + return ( + self.device.status.get(self._fan_mode_dp_code) + if self._fan_mode_dp_code + else None + ) @property def swing_mode(self) -> str: diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index a40468fdc8f..7a80a51726d 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum import logging @@ -25,6 +24,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfTemperature, UnitOfVolume, + UnitOfVolumetricFlux, ) DOMAIN = "tuya" @@ -67,6 +67,7 @@ PLATFORMS = [ Platform.SIREN, Platform.SWITCH, Platform.VACUUM, + Platform.VALVE, ] @@ -82,6 +83,7 @@ class WorkMode(StrEnum): class DPType(StrEnum): """Data point types.""" + BITMAP = "Bitmap" BOOLEAN = "Boolean" ENUM = "Enum" INTEGER = "Integer" @@ -98,16 +100,18 @@ class DPCode(StrEnum): AIR_QUALITY = "air_quality" AIR_QUALITY_INDEX = "air_quality_index" + ALARM_DELAY_TIME = "alarm_delay_time" + ALARM_MESSAGE = "alarm_message" + ALARM_MSG = "alarm_msg" ALARM_SWITCH = "alarm_switch" # Alarm switch ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume - ALARM_MESSAGE = "alarm_message" - ALARM_MSG = "alarm_msg" ANGLE_HORIZONTAL = "angle_horizontal" ANGLE_VERTICAL = "angle_vertical" ANION = "anion" # Ionizer unit ARM_DOWN_PERCENT = "arm_down_percent" ARM_UP_PERCENT = "arm_up_percent" + ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API BASIC_ANTI_FLICKER = "basic_anti_flicker" BASIC_DEVICE_VOLUME = "basic_device_volume" BASIC_FLIP = "basic_flip" @@ -143,8 +147,8 @@ class DPCode(StrEnum): CLEAN_AREA = "clean_area" CLEAN_TIME = "clean_time" CLICK_SUSTAIN_TIME = "click_sustain_time" - CLOUD_RECIPE_NUMBER = "cloud_recipe_number" CLOSED_OPENED_KIT = "closed_opened_kit" + CLOUD_RECIPE_NUMBER = "cloud_recipe_number" CO_STATE = "co_state" CO_STATUS = "co_status" CO_VALUE = "co_value" @@ -155,15 +159,23 @@ class DPCode(StrEnum): COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode - COOK_TEMPERATURE = "cook_temperature" - COOK_TIME = "cook_time" CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" CONTROL_2 = "control_2" CONTROL_3 = "control_3" CONTROL_BACK = "control_back" CONTROL_BACK_MODE = "control_back_mode" + COOK_TEMPERATURE = "cook_temperature" + COOK_TIME = "cook_time" COUNTDOWN = "countdown" # Countdown + COUNTDOWN_1 = "countdown_1" + COUNTDOWN_2 = "countdown_2" + COUNTDOWN_3 = "countdown_3" + COUNTDOWN_4 = "countdown_4" + COUNTDOWN_5 = "countdown_5" + COUNTDOWN_6 = "countdown_6" + COUNTDOWN_7 = "countdown_7" + COUNTDOWN_8 = "countdown_8" COUNTDOWN_LEFT = "countdown_left" COUNTDOWN_SET = "countdown_set" # Countdown setting CRY_DETECTION_SWITCH = "cry_detection_switch" @@ -176,6 +188,7 @@ class DPCode(StrEnum): DECIBEL_SWITCH = "decibel_switch" DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" DEHUMIDITY_SET_VALUE = "dehumidify_set_value" + DELAY_SET = "delay_set" DISINFECTION = "disinfection" DO_NOT_DISTURB = "do_not_disturb" DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor @@ -189,11 +202,11 @@ class DPCode(StrEnum): FAN_COOL = "fan_cool" # Cool wind FAN_DIRECTION = "fan_direction" # Fan direction FAN_HORIZONTAL = "fan_horizontal" # Horizontal swing flap angle + FAN_MODE = "fan_mode" FAN_SPEED = "fan_speed" FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed FAN_SWITCH = "fan_switch" - FAN_MODE = "fan_mode" FAN_VERTICAL = "fan_vertical" # Vertical swing flap angle FAR_DETECTION = "far_detection" FAULT = "fault" @@ -206,6 +219,7 @@ class DPCode(StrEnum): FLOODLIGHT_LIGHTNESS = "floodlight_lightness" FLOODLIGHT_SWITCH = "floodlight_switch" FORWARD_ENERGY_TOTAL = "forward_energy_total" + FROST = "frost" # Frost protection GAS_SENSOR_STATE = "gas_sensor_state" GAS_SENSOR_STATUS = "gas_sensor_status" GAS_SENSOR_VALUE = "gas_sensor_value" @@ -213,8 +227,13 @@ class DPCode(StrEnum): HUMIDITY = "humidity" # Humidity HUMIDITY_CURRENT = "humidity_current" # Current humidity HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity + HUMIDITY_OUTDOOR = "humidity_outdoor" # Outdoor humidity + HUMIDITY_OUTDOOR_1 = "humidity_outdoor_1" # Outdoor humidity + HUMIDITY_OUTDOOR_2 = "humidity_outdoor_2" # Outdoor humidity + HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity HUMIDITY_SET = "humidity_set" # Humidity setting HUMIDITY_VALUE = "humidity_value" # Humidity + INSTALLATION_HEIGHT = "installation_height" IPC_WORK_MODE = "ipc_work_mode" LED_TYPE_1 = "led_type_1" LED_TYPE_2 = "led_type_2" @@ -225,12 +244,18 @@ class DPCode(StrEnum): LEVEL_CURRENT = "level_current" LIGHT = "light" # Light LIGHT_MODE = "light_mode" + LIQUID_DEPTH = "liquid_depth" + LIQUID_DEPTH_MAX = "liquid_depth_max" + LIQUID_LEVEL_PERCENT = "liquid_level_percent" + LIQUID_STATE = "liquid_state" LOCK = "lock" # Lock / Child lock - MASTER_MODE = "master_mode" # alarm mode - MASTER_STATE = "master_state" # alarm state MACH_OPERATE = "mach_operate" MANUAL_FEED = "manual_feed" + MASTER_MODE = "master_mode" # alarm mode + MASTER_STATE = "master_state" # alarm state MATERIAL = "material" # Material + MAX_SET = "max_set" + MINI_SET = "mini_set" MODE = "mode" # Working mode / Mode MOODLIGHTING = "moodlighting" # Mood light MOTION_RECORD = "motion_record" @@ -241,6 +266,7 @@ class DPCode(StrEnum): MUFFLING = "muffling" # Muffling NEAR_DETECTION = "near_detection" OPPOSITE = "opposite" + OXYGEN = "oxygen" # Oxygen bar PAUSE = "pause" PERCENT_CONTROL = "percent_control" PERCENT_CONTROL_2 = "percent_control_2" @@ -248,7 +274,6 @@ class DPCode(StrEnum): PERCENT_STATE = "percent_state" PERCENT_STATE_2 = "percent_state_2" PERCENT_STATE_3 = "percent_state_3" - POSITION = "position" PHASE_A = "phase_a" PHASE_B = "phase_b" PHASE_C = "phase_c" @@ -258,20 +283,22 @@ class DPCode(StrEnum): PM25 = "pm25" PM25_STATE = "pm25_state" PM25_VALUE = "pm25_value" + POSITION = "position" POWDER_SET = "powder_set" # Powder POWER = "power" POWER_GO = "power_go" + POWER_TOTAL = "power_total" PREHEAT = "preheat" PREHEAT_1 = "preheat_1" PREHEAT_2 = "preheat_2" - POWER_TOTAL = "power_total" PRESENCE_STATE = "presence_state" PRESSURE_STATE = "pressure_state" PRESSURE_VALUE = "pressure_value" PUMP = "pump" PUMP_RESET = "pump_reset" # Water pump reset PUMP_TIME = "pump_time" # Water pump duration - OXYGEN = "oxygen" # Oxygen bar + RAIN_24H = "rain_24h" # Total daily rainfall in mm + RAIN_RATE = "rain_rate" # Rain intensity in mm/h RECORD_MODE = "record_mode" RECORD_SWITCH = "record_switch" # Recording switch RELAY_STATUS = "relay_status" @@ -304,6 +331,7 @@ class DPCode(StrEnum): STATUS = "status" STERILIZATION = "sterilization" # Sterilization SUCTION = "suction" + SUPPLY_FREQUENCY = "supply_frequency" SWING = "swing" # Swing mode SWITCH = "switch" # Switch SWITCH_1 = "switch_1" # Switch 1 @@ -314,6 +342,8 @@ class DPCode(StrEnum): SWITCH_6 = "switch_6" # Switch 6 SWITCH_7 = "switch_7" # Switch 7 SWITCH_8 = "switch_8" # Switch 8 + SWITCH_ALARM_LIGHT = "switch_alarm_light" + SWITCH_ALARM_SOUND = "switch_alarm_sound" SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch SWITCH_CHARGE = "switch_charge" SWITCH_CONTROLLER = "switch_controller" @@ -350,14 +380,24 @@ class DPCode(StrEnum): TEMP_BOILING_C = "temp_boiling_c" TEMP_BOILING_F = "temp_boiling_f" TEMP_CONTROLLER = "temp_controller" + TEMP_CORRECTION = "temp_correction" TEMP_CURRENT = "temp_current" # Current temperature in °C - TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F TEMP_CURRENT_EXTERNAL = ( "temp_current_external" # Current external temperature in Celsius ) + TEMP_CURRENT_EXTERNAL_1 = ( + "temp_current_external_1" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_2 = ( + "temp_current_external_2" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_3 = ( + "temp_current_external_3" # Current external temperature in Celsius + ) TEMP_CURRENT_EXTERNAL_F = ( "temp_current_external_f" # Current external temperature in Fahrenheit ) + TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F @@ -371,17 +411,19 @@ class DPCode(StrEnum): TOTAL_CLEAN_COUNT = "total_clean_count" TOTAL_CLEAN_TIME = "total_clean_time" TOTAL_FORWARD_ENERGY = "total_forward_energy" - TOTAL_TIME = "total_time" TOTAL_PM = "total_pm" TOTAL_POWER = "total_power" + TOTAL_TIME = "total_time" TVOC = "tvoc" UPPER_TEMP = "upper_temp" UPPER_TEMP_F = "upper_temp_f" UV = "uv" # UV sterilization + UV_INDEX = "uv_index" UV_RUNTIME = "uv_runtime" # UV runtime VA_BATTERY = "va_battery" VA_HUMIDITY = "va_humidity" VA_TEMPERATURE = "va_temperature" + VALVE_STATE = "valve_state" VOC_STATE = "voc_state" VOC_VALUE = "voc_value" VOICE_SWITCH = "voice_switch" @@ -390,6 +432,7 @@ class DPCode(StrEnum): WARM = "warm" # Heat preservation WARM_TIME = "warm_time" # Heat preservation time WATER = "water" + WATER_LEVEL = "water_level" WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level WATER_TIME = "water_time" # Water usage duration @@ -399,10 +442,13 @@ class DPCode(StrEnum): WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" WINDSPEED = "windspeed" + WINDSPEED_AVG = "windspeed_avg" + WIND_DIRECT = "wind_direct" WIRELESS_BATTERYLOCK = "wireless_batterylock" WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode WORK_POWER = "work_power" + WORK_STATE_E = "work_state_e" @dataclass @@ -413,8 +459,6 @@ class UnitOfMeasurement: device_classes: set[str] aliases: set[str] = field(default_factory=set) - conversion_unit: str | None = None - conversion_fn: Callable[[float], float] | None = None # A tuple of available units of measurements we can work with. @@ -454,8 +498,6 @@ UNITS = ( SensorDeviceClass.CO, SensorDeviceClass.CO2, }, - conversion_unit=CONCENTRATION_PARTS_PER_MILLION, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfElectricCurrent.AMPERE, @@ -466,8 +508,6 @@ UNITS = ( unit=UnitOfElectricCurrent.MILLIAMPERE, aliases={"ma", "milliampere"}, device_classes={SensorDeviceClass.CURRENT}, - conversion_unit=UnitOfElectricCurrent.AMPERE, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfEnergy.WATT_HOUR, @@ -489,6 +529,11 @@ UNITS = ( aliases={"m3"}, device_classes={SensorDeviceClass.GAS}, ), + UnitOfMeasurement( + unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + aliases={"mm"}, + device_classes={SensorDeviceClass.PRECIPITATION_INTENSITY}, + ), UnitOfMeasurement( unit=LIGHT_LUX, aliases={"lux"}, @@ -496,7 +541,9 @@ UNITS = ( ), UnitOfMeasurement( unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - aliases={"ug/m3", "µg/m3", "ug/m³"}, + # The μ-char has 2 unicode variants \u00b5 and \u03bc + # The \u03bc variant (Greek Mu char) is recommended + aliases={"ug/m3", "\u03bcg/m3", "\u00b5g/m3", "ug/m³"}, device_classes={ SensorDeviceClass.NITROGEN_DIOXIDE, SensorDeviceClass.NITROGEN_MONOXIDE, @@ -523,8 +570,6 @@ UNITS = ( SensorDeviceClass.SULPHUR_DIOXIDE, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, }, - conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - conversion_fn=lambda x: x * 1000, ), UnitOfMeasurement( unit=UnitOfPower.WATT, @@ -592,8 +637,6 @@ UNITS = ( unit=UnitOfElectricPotential.MILLIVOLT, aliases={"mv", "millivolt"}, device_classes={SensorDeviceClass.VOLTAGE}, - conversion_unit=UnitOfElectricPotential.VOLT, - conversion_fn=lambda x: x / 1000, ), ) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 315075e7f37..43e3f20deb4 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from tuya_sharing import CustomerDevice, Manager @@ -21,7 +21,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -38,6 +40,34 @@ class TuyaCoverEntityDescription(CoverEntityDescription): COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { + # Garage Door Opener + # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + "ckmkzq": ( + TuyaCoverEntityDescription( + key=DPCode.SWITCH_1, + translation_key="indexed_door", + translation_placeholders={"index": "1"}, + current_state=DPCode.DOORCONTACT_STATE, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_2, + translation_key="indexed_door", + translation_placeholders={"index": "2"}, + current_state=DPCode.DOORCONTACT_STATE_2, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_3, + translation_key="indexed_door", + translation_placeholders={"index": "3"}, + current_state=DPCode.DOORCONTACT_STATE_3, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + ), # Curtain # Note: Multiple curtains isn't documented # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df @@ -52,14 +82,16 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - translation_key="curtain_2", + translation_key="indexed_curtain", + translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_STATE_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_3, - translation_key="curtain_3", + translation_key="indexed_curtain", + translation_placeholders={"index": "3"}, current_position=DPCode.PERCENT_STATE_3, set_position=DPCode.PERCENT_CONTROL_3, device_class=CoverDeviceClass.CURTAIN, @@ -84,31 +116,6 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { device_class=CoverDeviceClass.BLIND, ), ), - # Garage Door Opener - # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee - "ckmkzq": ( - TuyaCoverEntityDescription( - key=DPCode.SWITCH_1, - translation_key="door", - current_state=DPCode.DOORCONTACT_STATE, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - TuyaCoverEntityDescription( - key=DPCode.SWITCH_2, - translation_key="door_2", - current_state=DPCode.DOORCONTACT_STATE_2, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - TuyaCoverEntityDescription( - key=DPCode.SWITCH_3, - translation_key="door_3", - current_state=DPCode.DOORCONTACT_STATE_3, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - ), # Curtain Switch # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 "clkg": ( @@ -121,7 +128,8 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - translation_key="curtain_2", + translation_key="indexed_curtain", + translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_CONTROL_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, @@ -195,7 +203,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): self._attr_supported_features = CoverEntityFeature(0) # Check if this cover is based on a switch or has controls - if self.find_dpcode(description.key, prefer_function=True): + if get_dpcode(self.device, description.key): if device.function[description.key].type == "Boolean": self._attr_supported_features |= ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -332,10 +340,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if self._set_position is None: - raise RuntimeError( - "Cannot set position, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by CoverEntityFeature.SET_POSITION + assert self._set_position is not None self._send_command( [ @@ -363,10 +370,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - if self._tilt is None: - raise RuntimeError( - "Cannot set tilt, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by CoverEntityFeature.SET_TILT_POSITION + assert self._tilt is not None self._send_command( [ diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index cc258560067..0ae0f793afd 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -2,11 +2,7 @@ from __future__ import annotations -import base64 -from dataclasses import dataclass -import json -import struct -from typing import Any, Literal, Self, overload +from typing import Any, Literal, overload from tuya_sharing import CustomerDevice, Manager @@ -15,11 +11,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType -from .util import remap_value +from .models import EnumTypeData, IntegerTypeData _DPTYPE_MAPPING: dict[str, DPType] = { - "Bitmap": DPType.RAW, - "bitmap": DPType.RAW, + "bitmap": DPType.BITMAP, "bool": DPType.BOOLEAN, "enum": DPType.ENUM, "json": DPType.JSON, @@ -29,118 +24,6 @@ _DPTYPE_MAPPING: dict[str, DPType] = { } -@dataclass -class IntegerTypeData: - """Integer Type Data.""" - - dpcode: DPCode - min: int - max: int - scale: float - step: float - unit: str | None = None - type: str | None = None - - @property - def max_scaled(self) -> float: - """Return the max scaled.""" - return self.scale_value(self.max) - - @property - def min_scaled(self) -> float: - """Return the min scaled.""" - return self.scale_value(self.min) - - @property - def step_scaled(self) -> float: - """Return the step scaled.""" - return self.step / (10**self.scale) - - def scale_value(self, value: float) -> float: - """Scale a value.""" - return value / (10**self.scale) - - def scale_value_back(self, value: float) -> int: - """Return raw value for scaled.""" - return int(value * (10**self.scale)) - - def remap_value_to( - self, - value: float, - to_min: float = 0, - to_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from this range to a new range.""" - return remap_value(value, self.min, self.max, to_min, to_max, reverse) - - def remap_value_from( - self, - value: float, - from_min: float = 0, - from_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from its current range to this range.""" - return remap_value(value, from_min, from_max, self.min, self.max, reverse) - - @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None: - """Load JSON string and return a IntegerTypeData object.""" - if not (parsed := json.loads(data)): - return None - - return cls( - dpcode, - min=int(parsed["min"]), - max=int(parsed["max"]), - scale=float(parsed["scale"]), - step=max(float(parsed["step"]), 1), - unit=parsed.get("unit"), - type=parsed.get("type"), - ) - - -@dataclass -class EnumTypeData: - """Enum Type Data.""" - - dpcode: DPCode - range: list[str] - - @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None: - """Load JSON string and return a EnumTypeData object.""" - if not (parsed := json.loads(data)): - return None - return cls(dpcode, **parsed) - - -@dataclass -class ElectricityTypeData: - """Electricity Type Data.""" - - electriccurrent: str | None = None - power: str | None = None - voltage: str | None = None - - @classmethod - def from_json(cls, data: str) -> Self: - """Load JSON string and return a ElectricityTypeData object.""" - return cls(**json.loads(data.lower())) - - @classmethod - def from_raw(cls, data: str) -> Self: - """Decode base64 string and return a ElectricityTypeData object.""" - raw = base64.b64decode(data) - voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 - electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 - power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 - return cls( - electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage) - ) - - class TuyaEntity(Entity): """Tuya base device.""" @@ -189,22 +72,17 @@ class TuyaEntity(Entity): dptype: Literal[DPType.INTEGER], ) -> IntegerTypeData | None: ... - @overload def find_dpcode( self, dpcodes: str | DPCode | tuple[DPCode, ...] | None, *, prefer_function: bool = False, - ) -> DPCode | None: ... + dptype: DPType, + ) -> EnumTypeData | IntegerTypeData | None: + """Find type information for a matching DP code available for this device.""" + if dptype not in (DPType.ENUM, DPType.INTEGER): + raise NotImplementedError("Only ENUM and INTEGER types are supported") - def find_dpcode( - self, - dpcodes: str | DPCode | tuple[DPCode, ...] | None, - *, - prefer_function: bool = False, - dptype: DPType | None = None, - ) -> DPCode | EnumTypeData | IntegerTypeData | None: - """Find a matching DP code available on for this device.""" if dpcodes is None: return None @@ -217,11 +95,6 @@ class TuyaEntity(Entity): if prefer_function: order = ["function", "status_range"] - # When we are not looking for a specific datatype, we can append status for - # searching - if not dptype: - order.append("status") - for dpcode in dpcodes: for key in order: if dpcode not in getattr(self.device, key): @@ -250,9 +123,6 @@ class TuyaEntity(Entity): continue return integer_type - if dptype not in (DPType.ENUM, DPType.INTEGER): - return dpcode - return None def get_dptype( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 3b951e75da1..12b6b11a297 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -22,17 +22,54 @@ from homeassistant.util.percentage import ( from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData, IntegerTypeData +from .util import get_dpcode + +_DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) +_OSCILLATE_DPCODES = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) +_SPEED_DPCODES = ( + DPCode.FAN_SPEED_PERCENT, + DPCode.FAN_SPEED, + DPCode.SPEED, + DPCode.FAN_SPEED_ENUM, +) +_SWITCH_DPCODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) TUYA_SUPPORT_TYPE = { - "fs", # Fan - "fsd", # Fan with Light - "fskg", # Fan wall switch - "kj", # Air Purifier - "cs", # Dehumidifier + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs", + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs", + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd", + # Fan wall switch + "fskg", + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj", + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks", } +def _has_a_valid_dpcode(device: CustomerDevice) -> bool: + """Check if the device has at least one valid DP code.""" + properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [ + # Main control switch + _SWITCH_DPCODES, + # Other properties + _SPEED_DPCODES, + _OSCILLATE_DPCODES, + _DIRECTION_DPCODES, + ] + return any(get_dpcode(device, code) for code in properties_to_check) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -47,7 +84,7 @@ async def async_setup_entry( entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if device and device.category in TUYA_SUPPORT_TYPE: + if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device): entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) @@ -77,9 +114,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True - ) + self._switch = get_dpcode(self.device, _SWITCH_DPCODES) self._attr_preset_modes = [] if enum_type := self.find_dpcode( @@ -90,31 +125,23 @@ class TuyaFanEntity(TuyaEntity, FanEntity): self._attr_preset_modes = enum_type.range # Find speed controls, can be either percentage or a set of speeds - dpcodes = ( - DPCode.FAN_SPEED_PERCENT, - DPCode.FAN_SPEED, - DPCode.SPEED, - DPCode.FAN_SPEED_ENUM, - ) if int_type := self.find_dpcode( - dpcodes, dptype=DPType.INTEGER, prefer_function=True + _SPEED_DPCODES, dptype=DPType.INTEGER, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speed = int_type elif enum_type := self.find_dpcode( - dpcodes, dptype=DPType.ENUM, prefer_function=True + _SPEED_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speeds = enum_type - if dpcode := self.find_dpcode( - (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL), prefer_function=True - ): + if dpcode := get_dpcode(self.device, _OSCILLATE_DPCODES): self._oscillate = dpcode self._attr_supported_features |= FanEntityFeature.OSCILLATE if enum_type := self.find_dpcode( - DPCode.FAN_DIRECTION, dptype=DPType.ENUM, prefer_function=True + _DIRECTION_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._direction = enum_type self._attr_supported_features |= FanEntityFeature.DIRECTION @@ -254,7 +281,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity): return int(self._speed.remap_value_to(value, 1, 100)) if self._speeds is not None: - if (value := self.device.status.get(self._speeds.dpcode)) is None: + if ( + value := self.device.status.get(self._speeds.dpcode) + ) is None or value not in self._speeds.range: return None return ordered_list_item_to_percentage(self._speeds.range, value) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index f8fd9237ffc..cb08ccaf476 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -19,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError, get_dpcode @dataclass(frozen=True) @@ -33,6 +35,20 @@ class TuyaHumidifierEntityDescription(HumidifierEntityDescription): humidity: DPCode | None = None +def _has_a_valid_dpcode( + device: CustomerDevice, description: TuyaHumidifierEntityDescription +) -> bool: + """Check if the device has at least one valid DP code.""" + properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [ + # Main control switch + description.dpcode or DPCode(description.key), + # Other humidity properties + description.current_humidity, + description.humidity, + ] + return any(get_dpcode(device, code) for code in properties_to_check) + + HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { # Dehumidifier # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha @@ -69,7 +85,9 @@ async def async_setup_entry( entities: list[TuyaHumidifierEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if description := HUMIDIFIERS.get(device.category): + if ( + description := HUMIDIFIERS.get(device.category) + ) and _has_a_valid_dpcode(device, description): entities.append( TuyaHumidifierEntity(device, hass_data.manager, description) ) @@ -103,8 +121,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): self._attr_unique_id = f"{super().unique_id}{description.key}" # Determine main switch DPCode - self._switch_dpcode = self.find_dpcode( - description.dpcode or DPCode(description.key), prefer_function=True + self._switch_dpcode = get_dpcode( + self.device, description.dpcode or DPCode(description.key) ) # Determine humidity parameters @@ -168,17 +186,28 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": True}]) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": False}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self._set_humidity is None: - raise RuntimeError( - "Cannot set humidity, device doesn't provide methods to set it" + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.humidity, ) self._send_command( diff --git a/homeassistant/components/tuya/icons.json b/homeassistant/components/tuya/icons.json index e28371f2b3d..04a701b4764 100644 --- a/homeassistant/components/tuya/icons.json +++ b/homeassistant/components/tuya/icons.json @@ -15,6 +15,9 @@ }, "tilt": { "default": "mdi:spirit-level" + }, + "valve": { + "default": "mdi:pipe-valve" } }, "button": { @@ -370,6 +373,12 @@ }, "sterilization": { "default": "mdi:minus-circle-outline" + }, + "arm_beep": { + "default": "mdi:volume-high" + }, + "siren": { + "default": "mdi:alarm-light" } } } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 67a94c4e267..673e9b1ffb3 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -12,9 +12,11 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_WHITE, ColorMode, LightEntity, LightEntityDescription, + color_supported, filter_supported_color_modes, ) from homeassistant.const import EntityCategory @@ -25,8 +27,9 @@ from homeassistant.util import color as color_util from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode -from .entity import IntegerTypeData, TuyaEntity -from .util import remap_value +from .entity import TuyaEntity +from .models import IntegerTypeData +from .util import get_dpcode, remap_value @dataclass @@ -119,7 +122,8 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { # Based on multiple reports: manufacturer customized Dimmer 2 switches TuyaLightEntityDescription( key=DPCode.SWITCH_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, ), ), @@ -135,6 +139,23 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE, ), ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="indexed_light", + translation_placeholders={"index": "2"}, + brightness=DPCode.BRIGHT_VALUE_1, + ), + ), # Ceiling Fan Light # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v "fsd": ( @@ -176,6 +197,17 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), + # Wake Up Light II + # Not documented + "hxd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light", + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_max=DPCode.BRIGHTNESS_MAX_1, + brightness_min=DPCode.BRIGHTNESS_MIN_1, + ), + ), # Humidifier Light # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b "jsq": ( @@ -214,6 +246,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), # Unknown light product # Found as VECINO RGBW as provided by diagnostics # Not documented @@ -275,21 +316,24 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "tgkg": ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, brightness_max=DPCode.BRIGHTNESS_MAX_1, brightness_min=DPCode.BRIGHTNESS_MIN_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_2, brightness_max=DPCode.BRIGHTNESS_MAX_2, brightness_min=DPCode.BRIGHTNESS_MIN_2, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_3, - translation_key="light_3", + translation_key="indexed_light", + translation_placeholders={"index": "3"}, brightness=DPCode.BRIGHT_VALUE_3, brightness_max=DPCode.BRIGHTNESS_MAX_3, brightness_min=DPCode.BRIGHTNESS_MIN_3, @@ -307,26 +351,17 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_2, ), ), - # Wake Up Light II - # Not documented - "hxd": ( - TuyaLightEntityDescription( - key=DPCode.SWITCH_LED, - translation_key="light", - brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), - brightness_max=DPCode.BRIGHTNESS_MAX_1, - brightness_min=DPCode.BRIGHTNESS_MIN_1, - ), - ), # Outdoor Flood Light # Not documented "tyd": ( @@ -378,22 +413,6 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_temp=DPCode.TEMP_CONTROLLER, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( - TuyaLightEntityDescription( - key=DPCode.LIGHT, - name=None, - color_mode=DPCode.WORK_MODE, - brightness=DPCode.BRIGHT_VALUE, - color_temp=DPCode.TEMP_VALUE, - ), - TuyaLightEntityDescription( - key=DPCode.SWITCH_LED, - translation_key="light_2", - brightness=DPCode.BRIGHT_VALUE_1, - ), - ), } # Socket (duplicate of `kg`) @@ -478,6 +497,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _color_data_type: ColorTypeData | None = None _color_mode: DPCode | None = None _color_temp: IntegerTypeData | None = None + _white_color_mode = ColorMode.COLOR_TEMP _fixed_color_mode: ColorMode | None = None _attr_min_color_temp_kelvin = 2000 # 500 Mireds _attr_max_color_temp_kelvin = 6500 # 153 Mireds @@ -495,9 +515,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): color_modes: set[ColorMode] = {ColorMode.ONOFF} # Determine DPCodes - self._color_mode_dpcode = self.find_dpcode( - description.color_mode, prefer_function=True - ) + self._color_mode_dpcode = get_dpcode(self.device, description.color_mode) if int_type := self.find_dpcode( description.brightness, dptype=DPType.INTEGER, prefer_function=True @@ -511,14 +529,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): description.brightness_min, dptype=DPType.INTEGER ) - if int_type := self.find_dpcode( - description.color_temp, dptype=DPType.INTEGER, prefer_function=True - ): - self._color_temp = int_type - color_modes.add(ColorMode.COLOR_TEMP) - if ( - dpcode := self.find_dpcode(description.color_data, prefer_function=True) + dpcode := get_dpcode(self.device, description.color_data) ) and self.get_dptype(dpcode) == DPType.JSON: self._color_data_dpcode = dpcode color_modes.add(ColorMode.HS) @@ -542,6 +554,26 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ): self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2 + # Check if the light has color temperature + if int_type := self.find_dpcode( + description.color_temp, dptype=DPType.INTEGER, prefer_function=True + ): + self._color_temp = int_type + color_modes.add(ColorMode.COLOR_TEMP) + # If light has color but does not have color_temp, check if it has + # work_mode "white" + elif ( + color_supported(color_modes) + and ( + color_mode_enum := self.find_dpcode( + description.color_mode, dptype=DPType.ENUM, prefer_function=True + ) + ) + and WorkMode.WHITE.value in color_mode_enum.range + ): + color_modes.add(ColorMode.WHITE) + self._white_color_mode = ColorMode.WHITE + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) if len(self._attr_supported_color_modes) == 1: # If the light supports only a single color mode, set it now @@ -556,15 +588,17 @@ class TuyaLightEntity(TuyaEntity, LightEntity): """Turn on or control the light.""" commands = [{"code": self.entity_description.key, "value": True}] - if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: - if self._color_mode_dpcode: - commands += [ - { - "code": self._color_mode_dpcode, - "value": WorkMode.WHITE, - }, - ] + if self._color_mode_dpcode and ( + ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs + ): + commands += [ + { + "code": self._color_mode_dpcode, + "value": WorkMode.WHITE, + }, + ] + if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: commands += [ { "code": self._color_temp.dpcode, @@ -586,6 +620,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): or ( ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS + and ATTR_WHITE not in kwargs and ATTR_COLOR_TEMP_KELVIN not in kwargs ) ): @@ -628,8 +663,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity): }, ] - elif ATTR_BRIGHTNESS in kwargs and self._brightness: - brightness = kwargs[ATTR_BRIGHTNESS] + elif self._brightness and (ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs): + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + else: + brightness = kwargs[ATTR_WHITE] # If there is a min/max value, the brightness is actually limited. # Meaning it is actually not on a 0-255 scale. @@ -745,15 +783,15 @@ class TuyaLightEntity(TuyaEntity, LightEntity): # The light supports only a single color mode, return it return self._fixed_color_mode - # The light supports both color temperature and HS, determine which mode the - # light is in. We consider it to be in HS color mode, when work mode is anything - # else than "white". + # The light supports both white (with or without adjustable color temperature) + # and HS, determine which mode the light is in. We consider it to be in HS color + # mode, when work mode is anything else than "white". if ( self._color_mode_dpcode and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE ): return ColorMode.HS - return ColorMode.COLOR_TEMP + return self._white_color_mode def _get_color_data(self) -> ColorData | None: """Get current color data from device.""" diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py new file mode 100644 index 00000000000..82cf5ebd200 --- /dev/null +++ b/homeassistant/components/tuya/models.py @@ -0,0 +1,140 @@ +"""Tuya Home Assistant Base Device Model.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass +import json +import struct +from typing import Self + +from .const import DPCode +from .util import remap_value + + +@dataclass +class IntegerTypeData: + """Integer Type Data.""" + + dpcode: DPCode + min: int + max: int + scale: float + step: float + unit: str | None = None + type: str | None = None + + @property + def max_scaled(self) -> float: + """Return the max scaled.""" + return self.scale_value(self.max) + + @property + def min_scaled(self) -> float: + """Return the min scaled.""" + return self.scale_value(self.min) + + @property + def step_scaled(self) -> float: + """Return the step scaled.""" + return self.step / (10**self.scale) + + def scale_value(self, value: float) -> float: + """Scale a value.""" + return value / (10**self.scale) + + def scale_value_back(self, value: float) -> int: + """Return raw value for scaled.""" + return int(value * (10**self.scale)) + + def remap_value_to( + self, + value: float, + to_min: float = 0, + to_max: float = 255, + reverse: bool = False, + ) -> float: + """Remap a value from this range to a new range.""" + return remap_value(value, self.min, self.max, to_min, to_max, reverse) + + def remap_value_from( + self, + value: float, + from_min: float = 0, + from_max: float = 255, + reverse: bool = False, + ) -> float: + """Remap a value from its current range to this range.""" + return remap_value(value, from_min, from_max, self.min, self.max, reverse) + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None: + """Load JSON string and return a IntegerTypeData object.""" + if not (parsed := json.loads(data)): + return None + + return cls( + dpcode, + min=int(parsed["min"]), + max=int(parsed["max"]), + scale=float(parsed["scale"]), + step=max(float(parsed["step"]), 1), + unit=parsed.get("unit"), + type=parsed.get("type"), + ) + + +@dataclass +class EnumTypeData: + """Enum Type Data.""" + + dpcode: DPCode + range: list[str] + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None: + """Load JSON string and return a EnumTypeData object.""" + if not (parsed := json.loads(data)): + return None + return cls(dpcode, **parsed) + + +class ComplexValue: + """Complex value (for JSON/RAW parsing).""" + + @classmethod + def from_json(cls, data: str) -> Self: + """Load JSON string and return a ComplexValue object.""" + raise NotImplementedError("from_json is not implemented for this type") + + @classmethod + def from_raw(cls, data: str) -> Self | None: + """Decode base64 string and return a ComplexValue object.""" + raise NotImplementedError("from_raw is not implemented for this type") + + +@dataclass +class ElectricityValue(ComplexValue): + """Electricity complex value.""" + + electriccurrent: str | None = None + power: str | None = None + voltage: str | None = None + + @classmethod + def from_json(cls, data: str) -> Self: + """Load JSON string and return a ElectricityValue object.""" + return cls(**json.loads(data.lower())) + + @classmethod + def from_raw(cls, data: str) -> Self | None: + """Decode base64 string and return a ElectricityValue object.""" + raw = base64.b64decode(data) + if len(raw) == 0: + return None + voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 + electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 + power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 + return cls( + electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage) + ) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index d4fe7836daa..7fadaa0489b 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -5,6 +5,7 @@ from __future__ import annotations from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( + DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,22 +16,22 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .const import ( + DEVICE_CLASS_UNITS, + DOMAIN, + LOGGER, + TUYA_DISCOVERY_NEW, + DPCode, + DPType, +) +from .entity import TuyaEntity +from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( - NumberEntityDescription( - key=DPCode.ALARM_TIME, - translation_key="time", - entity_category=EntityCategory.CONFIG, - ), - ), # Smart Kettle # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 "bh": ( @@ -64,6 +65,17 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="alarm_duration", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -76,6 +88,24 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { translation_key="voice_times", ), ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="time", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + NumberEntityDescription( + key=DPCode.TEMP, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + ), # Human Presence Sensor # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs "hps": ( @@ -102,6 +132,20 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.DISTANCE, ), ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + NumberEntityDescription( + key=DPCode.TEMP_SET, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + NumberEntityDescription( + key=DPCode.TEMP_SET_F, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + ), # Coffee maker # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f "kfj": ( @@ -127,6 +171,30 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk + "mal": ( + NumberEntityDescription( + key=DPCode.DELAY_SET, + # This setting is called "Arm Delay" in the official Tuya app + translation_key="arm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_DELAY_TIME, + translation_key="alarm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_TIME, + # This setting is called "Siren Duration" in the official Tuya app + translation_key="siren_duration", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( @@ -156,6 +224,66 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Water Timer + "sfkzq": ( + # Controls the irrigation duration for the water valve + NumberEntityDescription( + key=DPCode.COUNTDOWN_1, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "1"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_2, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "2"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_3, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "3"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_4, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "4"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_5, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "5"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_6, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "6"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_7, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "7"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COUNTDOWN_8, + translation_key="indexed_irrigation_duration", + translation_placeholders={"index": "8"}, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( @@ -174,73 +302,6 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgkg": ( - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MIN_1, - translation_key="minimum_brightness", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MAX_1, - translation_key="maximum_brightness", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MIN_2, - translation_key="minimum_brightness_2", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MAX_2, - translation_key="maximum_brightness_2", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MIN_3, - translation_key="minimum_brightness_3", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MAX_3, - translation_key="maximum_brightness_3", - entity_category=EntityCategory.CONFIG, - ), - ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgq": ( - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MIN_1, - translation_key="minimum_brightness", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MAX_1, - translation_key="maximum_brightness", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MIN_2, - translation_key="minimum_brightness_2", - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.BRIGHTNESS_MAX_2, - translation_key="maximum_brightness_2", - entity_category=EntityCategory.CONFIG, - ), - ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": ( - NumberEntityDescription( - key=DPCode.SENSITIVITY, - translation_key="sensitivity", - entity_category=EntityCategory.CONFIG, - ), - ), # Fingerbot "szjqr": ( NumberEntityDescription( @@ -261,27 +322,116 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( NumberEntityDescription( - key=DPCode.TEMP, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, + key=DPCode.BRIGHTNESS_MIN_1, + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "1"}, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_1, + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "1"}, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_2, + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "2"}, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_2, + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "2"}, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_3, + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "3"}, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_3, + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "3"}, + entity_category=EntityCategory.CONFIG, ), ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgq": ( NumberEntityDescription( - key=DPCode.TEMP_SET, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, + key=DPCode.BRIGHTNESS_MIN_1, + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "1"}, + entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( - key=DPCode.TEMP_SET_F, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, + key=DPCode.BRIGHTNESS_MAX_1, + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "1"}, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MIN_2, + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "2"}, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.BRIGHTNESS_MAX_2, + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "2"}, + entity_category=EntityCategory.CONFIG, + ), + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + NumberEntityDescription( + key=DPCode.TEMP_CORRECTION, + translation_key="temp_correction", + entity_category=EntityCategory.CONFIG, + ), + ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + NumberEntityDescription( + key=DPCode.MAX_SET, + translation_key="alarm_maximum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.MINI_SET, + translation_key="alarm_minimum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.INSTALLATION_HEIGHT, + translation_key="installation_height", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.LIQUID_DEPTH_MAX, + translation_key="maximum_liquid_depth", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": ( + NumberEntityDescription( + key=DPCode.SENSITIVITY, + translation_key="sensitivity", + entity_category=EntityCategory.CONFIG, ), ), # Pool HeatPump @@ -292,17 +442,6 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.TEMPERATURE, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - NumberEntityDescription( - key=DPCode.ALARM_TIME, - translation_key="alarm_duration", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=NumberDeviceClass.DURATION, - entity_category=EntityCategory.CONFIG, - ), - ), } # Smart Camera - Low power consumption camera (duplicate of `sp`) @@ -363,6 +502,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self._attr_native_max_value = self._number.max_scaled self._attr_native_min_value = self._number.min_scaled self._attr_native_step = self._number.step_scaled + if description.native_unit_of_measurement is None: + self._attr_native_unit_of_measurement = int_type.unit # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. @@ -370,6 +511,9 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in NUMBER_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -377,24 +521,28 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in number entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> float | None: @@ -412,7 +560,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): def set_native_value(self, value: float) -> None: """Set new value.""" if self._number is None: - raise RuntimeError("Cannot set value, device doesn't provide type data") + raise ActionDPCodeNotFoundError(self.device, self.entity_description.key) self._send_command( [ diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 21f88156236..296a5e3cc2c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -18,6 +18,52 @@ from .entity import TuyaEntity # default instructions set of each category end up being a select. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SelectEntityDescription( + key=DPCode.CONTROL_BACK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="curtain_motor_mode", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="curtain_mode", + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SelectEntityDescription( + key=DPCode.ALARM_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.DEHUMIDITY_SET_ENUM, + translation_key="target_humidity", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SelectEntityDescription( + key=DPCode.WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="odor_elimination_mode", + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -27,6 +73,81 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SelectEntityDescription( + key=DPCode.LEVEL, + name="Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_1, + name="Side A Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_2, + name="Side B Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge + "fs": ( + SelectEntityDescription( + key=DPCode.FAN_VERTICAL, + entity_category=EntityCategory.CONFIG, + translation_key="vertical_fan_angle", + ), + SelectEntityDescription( + key=DPCode.FAN_HORIZONTAL, + entity_category=EntityCategory.CONFIG, + translation_key="horizontal_fan_angle", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + SelectEntityDescription( + key=DPCode.SPRAY_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_spray_mode", + ), + SelectEntityDescription( + key=DPCode.LEVEL, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_level", + ), + SelectEntityDescription( + key=DPCode.MOODLIGHTING, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_moodlighting", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), # Coffee maker # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f "kfj": ( @@ -63,6 +184,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="light_mode", ), ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), # Heater # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm "qn": ( @@ -71,6 +206,25 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="temperature_level", ), ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + SelectEntityDescription( + key=DPCode.CISTERN, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_cistern", + ), + SelectEntityDescription( + key=DPCode.COLLECTION_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_collection", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_mode", + ), + ), # Smart Water Timer "sfkzq": ( # Irrigation will not be run within this set delay period @@ -128,6 +282,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), + # Fingerbot + "szjqr": ( + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="fingerbot_mode", + ), + ), # IoT Switch? # Note: Undocumented "tdq": ( @@ -158,17 +320,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="indexed_led_type", + translation_placeholders={"index": "1"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, - translation_key="led_type_2", + translation_key="indexed_led_type", + translation_placeholders={"index": "2"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_3, entity_category=EntityCategory.CONFIG, - translation_key="led_type_3", + translation_key="indexed_led_type", + translation_placeholders={"index": "3"}, ), ), # Dimmer @@ -177,165 +342,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="indexed_led_type", + translation_placeholders={"index": "1"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, - translation_key="led_type_2", - ), - ), - # Fingerbot - "szjqr": ( - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="fingerbot_mode", - ), - ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( - SelectEntityDescription( - key=DPCode.CISTERN, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_cistern", - ), - SelectEntityDescription( - key=DPCode.COLLECTION_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_collection", - ), - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_mode", - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge - "fs": ( - SelectEntityDescription( - key=DPCode.FAN_VERTICAL, - entity_category=EntityCategory.CONFIG, - translation_key="vertical_fan_angle", - ), - SelectEntityDescription( - key=DPCode.FAN_HORIZONTAL, - entity_category=EntityCategory.CONFIG, - translation_key="horizontal_fan_angle", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( - SelectEntityDescription( - key=DPCode.CONTROL_BACK_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="curtain_motor_mode", - ), - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="curtain_mode", - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - SelectEntityDescription( - key=DPCode.SPRAY_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_spray_mode", - ), - SelectEntityDescription( - key=DPCode.LEVEL, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_level", - ), - SelectEntityDescription( - key=DPCode.MOODLIGHTING, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_moodlighting", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": ( - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.DEHUMIDITY_SET_ENUM, - translation_key="target_humidity", - entity_category=EntityCategory.CONFIG, - ), - ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - SelectEntityDescription( - key=DPCode.ALARM_VOLUME, - translation_key="volume", - entity_category=EntityCategory.CONFIG, - ), - ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( - SelectEntityDescription( - key=DPCode.LEVEL, - name="Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - SelectEntityDescription( - key=DPCode.LEVEL_1, - name="Side A Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - SelectEntityDescription( - key=DPCode.LEVEL_2, - name="Side B Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", + translation_key="indexed_led_type", + translation_placeholders={"index": "2"}, ), ), } @@ -344,14 +358,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SELECTS["cz"] = SELECTS["kg"] -# Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SELECTS["pc"] = SELECTS["kg"] - # Smart Camera - Low power consumption camera (duplicate of `sp`) # Undocumented, see https://github.com/home-assistant/core/issues/132844 SELECTS["dghsxj"] = SELECTS["sp"] +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["pc"] = SELECTS["kg"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 912632c074b..fe7db2b28b9 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -2,18 +2,23 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from tuya_sharing import CustomerDevice, Manager from tuya_sharing.device import DeviceStatusRange from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -30,19 +35,42 @@ from . import TuyaConfigEntry from .const import ( DEVICE_CLASS_UNITS, DOMAIN, + LOGGER, TUYA_DISCOVERY_NEW, DPCode, DPType, UnitOfMeasurement, ) -from .entity import ElectricityTypeData, EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import ComplexValue, ElectricityValue, EnumTypeData, IntegerTypeData + +_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, +} @dataclass(frozen=True) class TuyaSensorEntityDescription(SensorEntityDescription): """Describes Tuya sensor entity.""" + complex_type: type[ComplexValue] | None = None subkey: str | None = None + state_conversion: Callable[[Any], StateType] | None = None # Commonly used battery sensors, that are reused in the sensors down below. @@ -89,78 +117,32 @@ BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( # end up being a sensor. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + # Single Phase power meter + # Note: Undocumented + "aqcz": ( TuyaSensorEntityDescription( - key=DPCode.GAS_SENSOR_VALUE, - translation_key="gas", + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( - key=DPCode.CH4_SENSOR_VALUE, - translation_key="gas", - name="Methane", + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( - key=DPCode.VOC_VALUE, - translation_key="voc", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_registry_enabled_default=False, ), - TuyaSensorEntityDescription( - key=DPCode.PM25_VALUE, - translation_key="pm25", - device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CO_VALUE, - translation_key="carbon_monoxide", - device_class=SensorDeviceClass.CO, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CO2_VALUE, - translation_key="carbon_dioxide", - device_class=SensorDeviceClass.CO2, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CH2O_VALUE, - translation_key="formaldehyde", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.BRIGHT_STATE, - translation_key="luminosity", - ), - TuyaSensorEntityDescription( - key=DPCode.BRIGHT_VALUE, - translation_key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.SMOKE_SENSOR_VALUE, - translation_key="smoke_amount", - entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, ), # Smart Kettle # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 @@ -182,6 +164,15 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="status", ), ), + # Curtain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre + "cl": ( + TuyaSensorEntityDescription( + key=DPCode.TIME_TOTAL, + translation_key="last_operation_duration", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), # CO2 Detector # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy "co2bj": ( @@ -202,6 +193,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -219,72 +211,10 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - ), - # Single Phase power meter - # Note: Undocumented - "aqcz": ( - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - ), # CO Detector # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v "cobj": ( @@ -293,6 +223,32 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + *BATTERY_SENSORS, + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e + "cs": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_INDOOR, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_INDOOR, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + TuyaSensorEntityDescription( + key=DPCode.WORK_STATE_E, + translation_key="odor_elimination_status", ), *BATTERY_SENSORS, ), @@ -334,7 +290,226 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + TuyaSensorEntityDescription( + key=DPCode.WATER_LEVEL, translation_key="water_level_state" + ), ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + TuyaSensorEntityDescription( + key=DPCode.GAS_SENSOR_VALUE, + translation_key="gas", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH4_SENSOR_VALUE, + translation_key="gas", + name="Methane", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + TuyaSensorEntityDescription( + key=DPCode.CO_VALUE, + translation_key="carbon_monoxide", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_STATE, + translation_key="luminosity", + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.SMOKE_SENSOR_VALUE, + translation_key="smoke_amount", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Circuit Breaker + # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 + "dlq": ( + TuyaSensorEntityDescription( + key=DPCode.TOTAL_FORWARD_ENERGY, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_NEUTRAL, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.SUPPLY_FREQUENCY, + translation_key="supply_frequency", + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityValue, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityValue, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityValue, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityValue, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityValue, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityValue, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityValue, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityValue, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityValue, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_registry_enabled_default=False, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 + "fs": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": BATTERY_SENSORS, # Air Quality Monitor # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv "hjjcy": ( @@ -359,6 +534,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -376,12 +552,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -393,6 +571,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, @@ -405,6 +584,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, @@ -425,6 +605,33 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 + "jsq": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_CURRENT, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_F, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LEVEL_CURRENT, + translation_key="water_level", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), # Methane Detector # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm "jwbj": ( @@ -443,6 +650,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -457,64 +665,66 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), - # IoT Switch - # Note: Undocumented - "tdq": ( + # Air Purifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 + "kj": ( TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, + key=DPCode.FILTER, + translation_key="filter_utilization", + entity_category=EntityCategory.DIAGNOSTIC, ), TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, + key=DPCode.PM25, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, + key=DPCode.TEMP, translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, + key=DPCode.HUMIDITY, translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.TVOC, + translation_key="total_volatile_organic_compound", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.BRIGHT_VALUE, - translation_key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, + key=DPCode.ECO2, + translation_key="concentration_carbon_dioxide", + device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_TIME, + translation_key="total_operating_time", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_PM, + translation_key="total_absorption_particles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.AIR_QUALITY, + translation_key="air_quality", ), - *BATTERY_SENSORS, ), # Luminance Sensor # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 @@ -546,6 +756,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), *BATTERY_SENSORS, ), @@ -585,6 +796,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -608,6 +820,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, @@ -620,12 +833,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm1", device_class=SensorDeviceClass.PM1, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -639,143 +854,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Gas Detector - # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw - "rqbj": ( - TuyaSensorEntityDescription( - key=DPCode.GAS_SENSOR_VALUE, - name=None, - translation_key="gas", - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Smart Water Timer - "sfkzq": ( - # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) - TuyaSensorEntityDescription( - key=DPCode.TIME_USE, - translation_key="total_watering_time", - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - ), - *BATTERY_SENSORS, - ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": BATTERY_SENSORS, - # Water Detector - # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli - "sj": BATTERY_SENSORS, - # Emergency Button - # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy - "sos": BATTERY_SENSORS, - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( - TuyaSensorEntityDescription( - key=DPCode.SENSOR_TEMPERATURE, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.SENSOR_HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.WIRELESS_ELECTRICITY, - translation_key="battery", - device_class=SensorDeviceClass.BATTERY, - entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Fingerbot - "szjqr": BATTERY_SENSORS, - # Solar Light - # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 - "tyndj": BATTERY_SENSORS, - # Volatile Organic Compound Sensor - # Note: Undocumented in cloud API docs, based on test device - "voc": ( - TuyaSensorEntityDescription( - key=DPCode.CO2_VALUE, - translation_key="carbon_dioxide", - device_class=SensorDeviceClass.CO2, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.PM25_VALUE, - translation_key="pm25", - device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CH2O_VALUE, - translation_key="formaldehyde", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VOC_VALUE, - translation_key="voc", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Thermostatic Radiator Valve - # Not documented - "wkf": BATTERY_SENSORS, - # Temperature and Humidity Sensor - # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 - "wsdcg": ( - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.BRIGHT_VALUE, - translation_key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), # Temperature and Humidity Sensor with External Probe # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 "qxj": ( @@ -797,6 +875,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_1, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_2, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_3, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, translation_key="humidity", @@ -809,243 +908,88 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR, + translation_key="humidity_outdoor", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_1, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_2, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_3, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ATMOSPHERIC_PRESSTURE, + translation_key="air_pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, translation_key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.WINDSPEED_AVG, + translation_key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.RAIN_24H, + translation_key="precipitation_today", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.RAIN_RATE, + translation_key="precipitation_intensity", + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.UV_INDEX, + translation_key="uv_index", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WIND_DIRECT, + translation_key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT, + state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)), + ), *BATTERY_SENSORS, ), - # Pressure Sensor - # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm - "ylcg": ( + # Gas Detector + # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + "rqbj": ( TuyaSensorEntityDescription( - key=DPCode.PRESSURE_VALUE, + key=DPCode.GAS_SENSOR_VALUE, name=None, - device_class=SensorDeviceClass.PRESSURE, + translation_key="gas", state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, ), - # Smoke Detector - # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 - "ywbj": ( - TuyaSensorEntityDescription( - key=DPCode.SMOKE_SENSOR_VALUE, - translation_key="smoke_amount", - entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": BATTERY_SENSORS, - # Smart Electricity Meter - # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 - "zndb": ( - TuyaSensorEntityDescription( - key=DPCode.FORWARD_ENERGY_TOTAL, - translation_key="total_energy", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.REVERSE_ENERGY_TOTAL, - translation_key="total_energy", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_POWER, - translation_key="total_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - ), - # Circuit Breaker - # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 - "dlq": ( - TuyaSensorEntityDescription( - key=DPCode.TOTAL_FORWARD_ENERGY, - translation_key="total_energy", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_NEUTRAL, - translation_key="total_production", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -1094,6 +1038,53 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="rolling_brush_life", state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.ELECTRICITY_LEFT, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Smart Water Timer + "sfkzq": ( + # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) + TuyaSensorEntityDescription( + key=DPCode.TIME_USE, + translation_key="total_watering_time", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + *BATTERY_SENSORS, + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": BATTERY_SENSORS, + # Emergency Button + # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + "sos": BATTERY_SENSORS, + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + TuyaSensorEntityDescription( + key=DPCode.SENSOR_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.SENSOR_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WIRELESS_ELECTRICITY, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 @@ -1111,22 +1102,38 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Curtain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre - "cl": ( + # Fingerbot + "szjqr": BATTERY_SENSORS, + # IoT Switch + # Note: Undocumented + "tdq": ( TuyaSensorEntityDescription( - key=DPCode.TIME_TOTAL, - translation_key="last_operation_duration", - entity_category=EntityCategory.DIAGNOSTIC, + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 - "jsq": ( TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_CURRENT, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( @@ -1136,82 +1143,116 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT_F, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.LEVEL_CURRENT, - translation_key="water_level", - entity_category=EntityCategory.DIAGNOSTIC, + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 - "kj": ( + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": BATTERY_SENSORS, + # Volatile Organic Compound Sensor + # Note: Undocumented in cloud API docs, based on test device + "voc": ( TuyaSensorEntityDescription( - key=DPCode.FILTER, - translation_key="filter_utilization", - entity_category=EntityCategory.DIAGNOSTIC, + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( - key=DPCode.PM25, + key=DPCode.PM25_VALUE, translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( - key=DPCode.TEMP, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.HUMIDITY, + key=DPCode.HUMIDITY_VALUE, translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), - TuyaSensorEntityDescription( - key=DPCode.TVOC, - translation_key="total_volatile_organic_compound", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.ECO2, - translation_key="concentration_carbon_dioxide", - device_class=SensorDeviceClass.CO2, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_TIME, - translation_key="total_operating_time", - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_PM, - translation_key="total_absorption_particles", - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.AIR_QUALITY, - translation_key="air_quality", - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 - "fs": ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": (*BATTERY_SENSORS,), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_registry_enabled_default=False, + ), + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": BATTERY_SENSORS, # eMylo Smart WiFi IR Remote # Air Conditioner Mate (Smart IR Socket) "wnykq": ( @@ -1232,6 +1273,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1248,25 +1290,236 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e - "cs": ( + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( TuyaSensorEntityDescription( - key=DPCode.TEMP_INDOOR, + key=DPCode.VA_TEMPERATURE, translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_INDOOR, + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Wireless Switch + # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp + "wxkg": BATTERY_SENSORS, # Pressure Sensor + # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + "ylcg": ( + TuyaSensorEntityDescription( + key=DPCode.PRESSURE_VALUE, + name=None, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + TuyaSensorEntityDescription( + key=DPCode.SMOKE_SENSOR_VALUE, + translation_key="smoke_amount", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + TuyaSensorEntityDescription( + key=DPCode.LIQUID_STATE, + translation_key="liquid_state", + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_DEPTH, + translation_key="depth", + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_LEVEL_PERCENT, + translation_key="liquid_level", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": BATTERY_SENSORS, + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + TuyaSensorEntityDescription( + key=DPCode.FORWARD_ENERGY_TOTAL, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.REVERSE_ENERGY_TOTAL, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_POWER, + translation_key="total_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityValue, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.SUPPLY_FREQUENCY, + translation_key="supply_frequency", + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityValue, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityValue, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_A, + translation_key="phase_a_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityValue, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityValue, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityValue, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_B, + translation_key="phase_b_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityValue, + subkey="voltage", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityValue, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityValue, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityValue, + subkey="voltage", + ), + ), + # VESKA-micro inverter + "znnbq": ( + TuyaSensorEntityDescription( + key=DPCode.REVERSE_ENERGY_TOTAL, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.POWER_TOTAL, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfPower.WATT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Pool HeatPump + "znrb": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ), # Soil sensor (Plant monitor) "zwjcy": ( @@ -1284,50 +1537,20 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # VESKA-micro inverter - "znnbq": ( - TuyaSensorEntityDescription( - key=DPCode.REVERSE_ENERGY_TOTAL, - translation_key="total_energy", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.POWER_TOTAL, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, - suggested_display_precision=0, - suggested_unit_of_measurement=UnitOfPower.WATT, - ), - ), - # Pool HeatPump - "znrb": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Wireless Switch - # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp - "wxkg": BATTERY_SENSORS, } # Socket (duplicate of `kg`) # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SENSORS["cz"] = SENSORS["kg"] -# Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SENSORS["pc"] = SENSORS["kg"] - # Smart Camera - Low power consumption camera (duplicate of `sp`) # Undocumented, see https://github.com/home-assistant/core/issues/132844 SENSORS["dghsxj"] = SENSORS["sp"] +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["pc"] = SENSORS["kg"] + async def async_setup_entry( hass: HomeAssistant, @@ -1401,6 +1624,9 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in SENSOR_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -1408,24 +1634,30 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in sensor entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None + self._attr_suggested_unit_of_measurement = None return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None + self._attr_suggested_unit_of_measurement = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> StateType: @@ -1445,12 +1677,13 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): if value is None: return None + # Convert value, if required + if (convert := self.entity_description.state_conversion) is not None: + return convert(value) + # Scale integer/float value if isinstance(self._type_data, IntegerTypeData): - scaled_value = self._type_data.scale_value(value) - if self._uom and self._uom.conversion_fn is not None: - return self._uom.conversion_fn(scaled_value) - return scaled_value + return self._type_data.scale_value(value) # Unexpected enum value if ( @@ -1461,16 +1694,23 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # Get subkey value from Json string. if self._type is DPType.JSON: - if self.entity_description.subkey is None: + if ( + self.entity_description.complex_type is None + or self.entity_description.subkey is None + ): return None - values = ElectricityTypeData.from_json(value) + values = self.entity_description.complex_type.from_json(value) return getattr(values, self.entity_description.subkey) if self._type is DPType.RAW: - if self.entity_description.subkey is None: + if ( + self.entity_description.complex_type is None + or self.entity_description.subkey is None + or (raw_values := self.entity_description.complex_type.from_raw(value)) + is None + ): return None - values = ElectricityTypeData.from_raw(value) - return getattr(values, self.entity_description.subkey) + return getattr(raw_values, self.entity_description.subkey) # Valid string or enum value return value diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 039442dafe5..8003dc2cf21 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -23,6 +23,14 @@ from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SirenEntityDescription( + key=DPCode.ALARM_SWITCH, + entity_category=EntityCategory.CONFIG, + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -44,14 +52,6 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - SirenEntityDescription( - key=DPCode.ALARM_SWITCH, - entity_category=EntityCategory.CONFIG, - ), - ), } # Smart Camera - Low power consumption camera (duplicate of `sp`) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ff67ac19806..fa15e34694c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -2,13 +2,13 @@ "config": { "step": { "reauth_user_code": { - "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app.", "data": { "user_code": "User code" } }, "user": { - "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app.", "data": { "user_code": "User code" } @@ -56,6 +56,18 @@ }, "tilt": { "name": "Tilt" + }, + "tankfull": { + "name": "Tank full" + }, + "defrost": { + "name": "Defrost" + }, + "valve": { + "name": "Valve" + }, + "wet": { + "name": "Wet" } }, "button": { @@ -85,20 +97,11 @@ "curtain": { "name": "[%key:component::cover::entity_component::curtain::name%]" }, - "curtain_2": { - "name": "Curtain 2" + "indexed_curtain": { + "name": "Curtain {index}" }, - "curtain_3": { - "name": "Curtain 3" - }, - "door": { - "name": "[%key:component::cover::entity_component::door::name%]" - }, - "door_2": { - "name": "Door 2" - }, - "door_3": { - "name": "Door 3" + "indexed_door": { + "name": "Door {index}" } }, "event": { @@ -122,11 +125,8 @@ "light": { "name": "[%key:component::light::title%]" }, - "light_2": { - "name": "Light 2" - }, - "light_3": { - "name": "Light 3" + "indexed_light": { + "name": "Light {index}" }, "night_light": { "name": "Night light" @@ -148,6 +148,9 @@ "heat_preservation_time": { "name": "Heat preservation time" }, + "indexed_irrigation_duration": { + "name": "Irrigation duration {index}" + }, "feed": { "name": "Feed" }, @@ -190,17 +193,11 @@ "maximum_brightness": { "name": "Maximum brightness" }, - "minimum_brightness_2": { - "name": "Minimum brightness 2" + "indexed_minimum_brightness": { + "name": "Minimum brightness {index}" }, - "maximum_brightness_2": { - "name": "Maximum brightness 2" - }, - "minimum_brightness_3": { - "name": "Minimum brightness 3" - }, - "maximum_brightness_3": { - "name": "Maximum brightness 3" + "indexed_maximum_brightness": { + "name": "Maximum brightness {index}" }, "move_down": { "name": "Move down" @@ -210,6 +207,30 @@ }, "down_delay": { "name": "Down delay" + }, + "temp_correction": { + "name": "Temperature correction" + }, + "arm_delay": { + "name": "Arm delay" + }, + "alarm_delay": { + "name": "Alarm delay" + }, + "siren_duration": { + "name": "Siren duration" + }, + "alarm_maximum": { + "name": "Alarm maximum" + }, + "alarm_minimum": { + "name": "Alarm minimum" + }, + "installation_height": { + "name": "Installation height" + }, + "maximum_liquid_depth": { + "name": "Maximum liquid depth" } }, "select": { @@ -275,16 +296,8 @@ "led": "LED" } }, - "led_type_2": { - "name": "Light 2 source type", - "state": { - "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", - "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", - "led": "[%key:component::tuya::entity::select::led_type::state::led%]" - } - }, - "led_type_3": { - "name": "Light 3 source type", + "indexed_led_type": { + "name": "Light {index} source type", "state": { "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", @@ -465,7 +478,7 @@ }, "blanket_level": { "state": { - "level_1": "Low", + "level_1": "[%key:common::state::low%]", "level_2": "Level 2", "level_3": "Level 3", "level_4": "Level 4", @@ -474,7 +487,14 @@ "level_7": "Level 7", "level_8": "Level 8", "level_9": "Level 9", - "level_10": "High" + "level_10": "[%key:common::state::high%]" + } + }, + "odor_elimination_mode": { + "name": "Odor elimination mode", + "state": { + "smart": "Smart", + "interim": "Interim" } } }, @@ -500,9 +520,33 @@ "temperature_external": { "name": "Probe temperature" }, + "indexed_temperature_external": { + "name": "Probe temperature channel {index}" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, + "humidity_outdoor": { + "name": "Outdoor humidity" + }, + "indexed_humidity_outdoor": { + "name": "Outdoor humidity channel {index}" + }, + "air_pressure": { + "name": "Air pressure" + }, + "precipitation_today": { + "name": "Total precipitation today" + }, + "precipitation_intensity": { + "name": "[%key:component::sensor::entity_component::precipitation_intensity::name%]" + }, + "uv_index": { + "name": "UV index" + }, + "wind_direction": { + "name": "[%key:component::sensor::entity_component::wind_direction::name%]" + }, "pm25": { "name": "[%key:component::sensor::entity_component::pm25::name%]" }, @@ -617,6 +661,14 @@ "water_level": { "name": "Water level" }, + "water_level_state": { + "name": "Water level", + "state": { + "level_1": "[%key:common::state::low%]", + "level_2": "[%key:common::state::medium%]", + "level_3": "[%key:common::state::full%]" + } + }, "total_watering_time": { "name": "Total watering time" }, @@ -680,6 +732,32 @@ }, "water_time": { "name": "Water usage duration" + }, + "odor_elimination_status": { + "name": "Status", + "state": { + "work": "Working", + "standby": "[%key:common::state::standby%]", + "charging": "[%key:common::state::charging%]", + "charge_done": "Charge done" + } + }, + "liquid_state": { + "name": "Liquid state", + "state": { + "normal": "[%key:common::state::normal%]", + "lower_alarm": "[%key:common::state::low%]", + "upper_alarm": "[%key:common::state::high%]" + } + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level": { + "name": "Liquid level" + }, + "supply_frequency": { + "name": "Supply frequency" } }, "switch": { @@ -725,86 +803,26 @@ "switch": { "name": "Switch" }, + "indexed_switch": { + "name": "Switch {index}" + }, "socket": { "name": "Socket" }, + "indexed_socket": { + "name": "Socket {index}" + }, "radio": { "name": "Radio" }, - "alarm_1": { - "name": "Alarm 1" - }, - "alarm_2": { - "name": "Alarm 2" - }, - "alarm_3": { - "name": "Alarm 3" - }, - "alarm_4": { - "name": "Alarm 4" + "indexed_alarm": { + "name": "Alarm {index}" }, "sleep_aid": { "name": "Sleep aid" }, - "switch_1": { - "name": "Switch 1" - }, - "switch_2": { - "name": "Switch 2" - }, - "switch_3": { - "name": "Switch 3" - }, - "switch_4": { - "name": "Switch 4" - }, - "switch_5": { - "name": "Switch 5" - }, - "switch_6": { - "name": "Switch 6" - }, - "switch_7": { - "name": "Switch 7" - }, - "switch_8": { - "name": "Switch 8" - }, - "usb_1": { - "name": "USB 1" - }, - "usb_2": { - "name": "USB 2" - }, - "usb_3": { - "name": "USB 3" - }, - "usb_4": { - "name": "USB 4" - }, - "usb_5": { - "name": "USB 5" - }, - "usb_6": { - "name": "USB 6" - }, - "socket_1": { - "name": "Socket 1" - }, - "socket_2": { - "name": "Socket 2" - }, - "socket_3": { - "name": "Socket 3" - }, - "socket_4": { - "name": "Socket 4" - }, - "socket_5": { - "name": "Socket 5" - }, - "socket_6": { - "name": "Socket 6" + "indexed_usb": { + "name": "USB {index}" }, "ionizer": { "name": "Ionizer" @@ -889,7 +907,26 @@ }, "sterilization": { "name": "Sterilization" + }, + "arm_beep": { + "name": "Arm beep" + }, + "siren": { + "name": "Siren" + }, + "frost_protection": { + "name": "Frost protection" } + }, + "valve": { + "indexed_valve": { + "name": "Valve {index}" + } + } + }, + "exceptions": { + "action_dpcode_not_found": { + "message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})." } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index a1d90c6ec2b..b9edc82ad71 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -37,6 +37,20 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SwitchEntityDescription( + key=DPCode.CONTROL_BACK, + translation_key="reverse", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OPPOSITE, + translation_key="reverse", + entity_category=EntityCategory.CONFIG, + ), + ), # EasyBaby # Undocumented, might have a wider use "cn": ( @@ -71,6 +85,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -131,6 +153,133 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Power", + icon="mdi:power", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_1, + name="Side A Power", + icon="mdi:alpha-a", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + name="Side B Power", + icon="mdi:alpha-b", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT, + name="Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_1, + name="Side A Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_2, + name="Side B Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="anion", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.HUMIDIFIER, + translation_key="humidification", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OXYGEN, + translation_key="oxygen_bar", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_COOL, + translation_key="natural_wind", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_BEEP, + translation_key="sound", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd": ( + SwitchEntityDescription( + key=DPCode.FAN_BEEP, + translation_key="sound", + entity_category=EntityCategory.CONFIG, + ), + ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="indexed_switch", + translation_placeholders={"index": "3"}, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="indexed_switch", + translation_placeholders={"index": "4"}, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="indexed_switch", + translation_placeholders={"index": "5"}, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="indexed_switch", + translation_placeholders={"index": "6"}, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_7, + translation_key="indexed_switch", + translation_placeholders={"index": "7"}, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_8, + translation_key="indexed_switch", + translation_placeholders={"index": "8"}, + ), + ), # Wake Up Light II # Not documented "hxd": ( @@ -140,22 +289,26 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="alarm_1", + translation_key="indexed_alarm", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="alarm_2", + translation_key="indexed_alarm", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="alarm_3", + translation_key="indexed_alarm", + translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - translation_key="alarm_4", + translation_key="indexed_alarm", + translation_placeholders={"index": "4"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( @@ -163,19 +316,23 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="sleep_aid", ), ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( SwitchEntityDescription( - key=DPCode.SWITCH_1, - translation_key="switch_1", - device_class=SwitchDeviceClass.OUTLET, + key=DPCode.SWITCH_SOUND, + translation_key="voice", + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( - key=DPCode.SWITCH_2, - translation_key="switch_2", - device_class=SwitchDeviceClass.OUTLET, + key=DPCode.SLEEP, + translation_key="sleep", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.STERILIZATION, + translation_key="sterilization", + entity_category=EntityCategory.CONFIG, ), ), # Switch @@ -188,67 +345,81 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="switch_1", + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="switch_2", + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="switch_3", + translation_key="indexed_switch", + translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="switch_4", + translation_key="indexed_switch", + translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - translation_key="switch_5", + translation_key="indexed_switch", + translation_placeholders={"index": "5"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - translation_key="switch_6", + translation_key="indexed_switch", + translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_7, - translation_key="switch_7", + translation_key="indexed_switch", + translation_placeholders={"index": "7"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_8, - translation_key="switch_8", + translation_key="indexed_switch", + translation_placeholders={"index": "8"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, - translation_key="usb_1", + translation_key="indexed_usb", + translation_placeholders={"index": "1"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB2, - translation_key="usb_2", + translation_key="indexed_usb", + translation_placeholders={"index": "2"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB3, - translation_key="usb_3", + translation_key="indexed_usb", + translation_placeholders={"index": "3"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB4, - translation_key="usb_4", + translation_key="indexed_usb", + translation_placeholders={"index": "4"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB5, - translation_key="usb_5", + translation_key="indexed_usb", + translation_placeholders={"index": "5"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB6, - translation_key="usb_6", + translation_key="indexed_usb", + translation_placeholders={"index": "6"}, ), SwitchEntityDescription( key=DPCode.SWITCH, @@ -303,6 +474,30 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + ), + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk + "mal": ( + SwitchEntityDescription( + key=DPCode.SWITCH_ALARM_SOUND, + # This switch is called "Arm Beep" in the official Tuya app + translation_key="arm_beep", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_ALARM_LIGHT, + # This switch is called "Siren" in the official Tuya app + translation_key="siren", + entity_category=EntityCategory.CONFIG, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( @@ -327,57 +522,69 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="socket_1", + translation_key="indexed_socket", + translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="socket_2", + translation_key="indexed_socket", + translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="socket_3", + translation_key="indexed_socket", + translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="socket_4", + translation_key="indexed_socket", + translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - translation_key="socket_5", + translation_key="indexed_socket", + translation_placeholders={"index": "5"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - translation_key="socket_6", + translation_key="indexed_socket", + translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, - translation_key="usb_1", + translation_key="indexed_usb", + translation_placeholders={"index": "1"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB2, - translation_key="usb_2", + translation_key="indexed_usb", + translation_placeholders={"index": "2"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB3, - translation_key="usb_3", + translation_key="indexed_usb", + translation_placeholders={"index": "3"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB4, - translation_key="usb_4", + translation_key="indexed_usb", + translation_placeholders={"index": "4"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB5, - translation_key="usb_5", + translation_key="indexed_usb", + translation_placeholders={"index": "5"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB6, - translation_key="usb_6", + translation_key="indexed_usb", + translation_placeholders={"index": "6"}, ), SwitchEntityDescription( key=DPCode.SWITCH, @@ -385,6 +592,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # AC charging + # Not documented + "qccdz": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Unknown product with switch capabilities # Fond in some diffusers, plugs and PIR flood lights # Not documented @@ -408,6 +623,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -429,18 +653,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": ( - SwitchEntityDescription( - key=DPCode.SWITCH_1, - translation_key="switch_1", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_2, - translation_key="switch_2", - ), - ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( @@ -528,34 +740,43 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Hejhome whitelabel Fingerbot - "znjxs": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - translation_key="switch", - ), - ), # IoT Switch? # Note: Undocumented "tdq": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="switch_1", + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="switch_2", + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="switch_3", + translation_key="indexed_switch", + translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="switch_4", + translation_key="indexed_switch", + translation_placeholders={"index": "4"}, + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="indexed_switch", + translation_placeholders={"index": "5"}, + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="indexed_switch", + translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( @@ -573,6 +794,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Gateway control + # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok + "wg2": ( + SwitchEntityDescription( + key=DPCode.MUFFLING, + translation_key="mute", + entity_category=EntityCategory.CONFIG, + ), + ), # Thermostat # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 "wk": ( @@ -581,6 +811,28 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), + SwitchEntityDescription( + key=DPCode.FROST, + translation_key="frost_protection", + entity_category=EntityCategory.CONFIG, + ), + ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, + device_class=SwitchDeviceClass.OUTLET, + ), ), # Thermostatic Radiator Valve # Not documented @@ -612,15 +864,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), - # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - translation_key="switch", - device_class=SwitchDeviceClass.OUTLET, - ), - ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( @@ -647,6 +890,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + SwitchEntityDescription( + key=DPCode.MUFFLING, + translation_key="mute", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Electricity Meter # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 "zndb": ( @@ -655,71 +907,11 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + # Hejhome whitelabel Fingerbot + "znjxs": ( SwitchEntityDescription( - key=DPCode.ANION, - translation_key="anion", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.HUMIDIFIER, - translation_key="humidification", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.OXYGEN, - translation_key="oxygen_bar", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.FAN_COOL, - translation_key="natural_wind", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.FAN_BEEP, - translation_key="sound", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.CHILD_LOCK, - translation_key="child_lock", - entity_category=EntityCategory.CONFIG, - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( - SwitchEntityDescription( - key=DPCode.CONTROL_BACK, - translation_key="reverse", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.OPPOSITE, - translation_key="reverse", - entity_category=EntityCategory.CONFIG, - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - SwitchEntityDescription( - key=DPCode.SWITCH_SOUND, - translation_key="voice", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.SLEEP, - translation_key="sleep", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.STERILIZATION, - translation_key="sterilization", - entity_category=EntityCategory.CONFIG, + key=DPCode.SWITCH, + translation_key="switch", ), ), # Pool HeatPump @@ -729,46 +921,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - name="Power", - icon="mdi:power", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.SWITCH_1, - name="Side A Power", - icon="mdi:alpha-a", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.SWITCH_2, - name="Side B Power", - icon="mdi:alpha-b", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT, - name="Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT_1, - name="Side A Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT_2, - name="Side B Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - ), } # Socket (duplicate of `pc`) diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index c1615b89c2d..af6a78c1476 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -2,6 +2,35 @@ from __future__ import annotations +from tuya_sharing import CustomerDevice + +from homeassistant.exceptions import ServiceValidationError + +from .const import DOMAIN, DPCode + + +def get_dpcode( + device: CustomerDevice, dpcodes: str | DPCode | tuple[DPCode, ...] | None +) -> DPCode | None: + """Get the first matching DPCode from the device or return None.""" + if dpcodes is None: + return None + + if isinstance(dpcodes, DPCode): + dpcodes = (dpcodes,) + elif isinstance(dpcodes, str): + dpcodes = (DPCode(dpcodes),) + + for dpcode in dpcodes: + if ( + dpcode in device.function + or dpcode in device.status + or dpcode in device.status_range + ): + return dpcode + + return None + def remap_value( value: float, @@ -15,3 +44,25 @@ def remap_value( 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.""" + + def __init__( + self, device: CustomerDevice, expected: str | DPCode | tuple[DPCode, ...] | None + ) -> None: + """Initialize the error with device and expected DP codes.""" + if expected is None: + expected = () # empty tuple for no expected codes + elif isinstance(expected, str): + expected = (DPCode(expected),) + + super().__init__( + translation_domain=DOMAIN, + translation_key="action_dpcode_not_found", + translation_placeholders={ + "expected": str(sorted([dp.value for dp in expected])), + "available": str(sorted(device.function.keys())), + }, + ) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index e36a682fa4e..c32d773c792 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -17,7 +17,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData +from .util import get_dpcode TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { @@ -76,7 +78,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): """Tuya Vacuum Device.""" _fan_speed: EnumTypeData | None = None - _battery_level: IntegerTypeData | None = None _attr_name = None def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: @@ -88,23 +89,24 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE ) - if self.find_dpcode(DPCode.PAUSE, prefer_function=True): + if get_dpcode(self.device, DPCode.PAUSE): self._attr_supported_features |= VacuumEntityFeature.PAUSE - if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) or ( - ( - enum_type := self.find_dpcode( - DPCode.MODE, dptype=DPType.ENUM, prefer_function=True - ) + self._return_home_use_switch_charge = False + if get_dpcode(self.device, DPCode.SWITCH_CHARGE): + self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME + self._return_home_use_switch_charge = True + elif ( + enum_type := self.find_dpcode( + DPCode.MODE, dptype=DPType.ENUM, prefer_function=True ) - and TUYA_MODE_RETURN_HOME in enum_type.range - ): + ) and TUYA_MODE_RETURN_HOME in enum_type.range: self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - if self.find_dpcode(DPCode.SEEK, prefer_function=True): + if get_dpcode(self.device, DPCode.SEEK): self._attr_supported_features |= VacuumEntityFeature.LOCATE - if self.find_dpcode(DPCode.POWER_GO, prefer_function=True): + if get_dpcode(self.device, DPCode.POWER_GO): self._attr_supported_features |= ( VacuumEntityFeature.STOP | VacuumEntityFeature.START ) @@ -116,19 +118,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._attr_fan_speed_list = enum_type.range self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED - if int_type := self.find_dpcode(DPCode.ELECTRICITY_LEFT, dptype=DPType.INTEGER): - self._attr_supported_features |= VacuumEntityFeature.BATTERY - self._battery_level = int_type - - @property - def battery_level(self) -> int | None: - """Return Tuya device state.""" - if self._battery_level is None or not ( - status := self.device.status.get(DPCode.ELECTRICITY_LEFT) - ): - return None - return round(self._battery_level.scale_value(status)) - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" @@ -159,12 +148,10 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): def return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" - self._send_command( - [ - {"code": DPCode.SWITCH_CHARGE, "value": True}, - {"code": DPCode.MODE, "value": TUYA_MODE_RETURN_HOME}, - ] - ) + if self._return_home_use_switch_charge: + self._send_command([{"code": DPCode.SWITCH_CHARGE, "value": True}]) + else: + self._send_command([{"code": DPCode.MODE, "value": TUYA_MODE_RETURN_HOME}]) def locate(self, **kwargs: Any) -> None: """Locate the device.""" diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py new file mode 100644 index 00000000000..06218c7030f --- /dev/null +++ b/homeassistant/components/tuya/valve.py @@ -0,0 +1,140 @@ +"""Support for Tuya valves.""" + +from __future__ import annotations + +from tuya_sharing import CustomerDevice, Manager + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TuyaConfigEntry +from .const import TUYA_DISCOVERY_NEW, DPCode +from .entity import TuyaEntity + +# All descriptions can be found here. Mostly the Boolean data types in the +# default instruction set of each category end up being a Valve. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +VALVES: dict[str, tuple[ValveEntityDescription, ...]] = { + # Smart Water Timer + "sfkzq": ( + ValveEntityDescription( + key=DPCode.SWITCH_1, + translation_key="indexed_valve", + translation_placeholders={"index": "1"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_2, + translation_key="indexed_valve", + translation_placeholders={"index": "2"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_3, + translation_key="indexed_valve", + translation_placeholders={"index": "3"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_4, + translation_key="indexed_valve", + translation_placeholders={"index": "4"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_5, + translation_key="indexed_valve", + translation_placeholders={"index": "5"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_6, + translation_key="indexed_valve", + translation_placeholders={"index": "6"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_7, + translation_key="indexed_valve", + translation_placeholders={"index": "7"}, + device_class=ValveDeviceClass.WATER, + ), + ValveEntityDescription( + key=DPCode.SWITCH_8, + translation_key="indexed_valve", + translation_placeholders={"index": "8"}, + device_class=ValveDeviceClass.WATER, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up tuya valves dynamically through tuya discovery.""" + hass_data = entry.runtime_data + + @callback + def async_discover_device(device_ids: list[str]) -> None: + """Discover and add a discovered tuya valve.""" + entities: list[TuyaValveEntity] = [] + for device_id in device_ids: + device = hass_data.manager.device_map[device_id] + if descriptions := VALVES.get(device.category): + entities.extend( + TuyaValveEntity(device, hass_data.manager, description) + for description in descriptions + if description.key in device.status + ) + + async_add_entities(entities) + + async_discover_device([*hass_data.manager.device_map]) + + entry.async_on_unload( + async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) + ) + + +class TuyaValveEntity(TuyaEntity, ValveEntity): + """Tuya Valve Device.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + def __init__( + self, + device: CustomerDevice, + device_manager: Manager, + description: ValveEntityDescription, + ) -> None: + """Init TuyaValveEntity.""" + super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return not self.device.status.get(self.entity_description.key, False) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.hass.async_add_executor_job( + self._send_command, [{"code": self.entity_description.key, "value": True}] + ) + + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.hass.async_add_executor_job( + self._send_command, [{"code": self.entity_description.key, "value": False}] + ) diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 84948a92e98..4fd3d34a51d 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -25,6 +25,7 @@ from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS from ..entity import UnifiEntity, UnifiEntityDescription if TYPE_CHECKING: + from .. import UnifiConfigEntry from .hub import UnifiHub CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) @@ -34,7 +35,7 @@ POLL_INTERVAL = timedelta(seconds=10) class UnifiEntityLoader: """UniFi Network integration handling platforms for entity registration.""" - def __init__(self, hub: UnifiHub) -> None: + def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: """Initialize the UniFi entity loader.""" self.hub = hub self.api_updaters = ( @@ -57,15 +58,16 @@ class UnifiEntityLoader: ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] - self._dataUpdateCoordinator = DataUpdateCoordinator( + self._data_update_coordinator = DataUpdateCoordinator( hub.hass, LOGGER, name="Unifi entity poller", + config_entry=config_entry, update_method=self._update_pollable_api_data, update_interval=POLL_INTERVAL, ) - self._update_listener = self._dataUpdateCoordinator.async_add_listener( + self._update_listener = self._data_update_coordinator.async_add_listener( update_callback=lambda: None ) diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index f2ed95a0c79..9ea887bdb29 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -39,7 +39,7 @@ class UnifiHub: self.hass = hass self.api = api self.config = UnifiConfig.from_config_entry(config_entry) - self.entity_loader = UnifiEntityLoader(self) + self.entity_loader = UnifiEntityLoader(self, config_entry) self._entity_helper = UnifiEntityHelper(hass, api) self.websocket = UnifiWebsocket(hass, api, self.signal_reachable) @@ -91,7 +91,9 @@ class UnifiHub: assert self.config.entry.unique_id is not None self.is_admin = self.api.sites[self.config.entry.unique_id].role == "admin" - self.config.entry.add_update_listener(self.async_config_entry_updated) + self.config.entry.async_on_unload( + self.config.entry.add_update_listener(self.async_config_entry_updated) + ) @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index dd255c57c13..c766af47951 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==83"], + "requirements": ["aiounifi==86"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index ba255bb7f7c..97a5ca67186 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -8,17 +8,21 @@ import logging from aiohttp.client_exceptions import ServerDisconnectedError from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import Bootstrap -from uiprotect.data.types import FirmwareReleaseChannel -from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.exceptions import BadRequest, ClientError, NotAuthorized # Import the test_util.anonymize module from the uiprotect package # in __init__ to ensure it gets imported in the executor since the # diagnostics module will not be imported in the executor. from uiprotect.test_util.anonymize import anonymize_data # noqa: F401 -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -33,7 +37,6 @@ from .const import ( DEVICES_THAT_ADOPT, DOMAIN, MIN_REQUIRED_PROTECT_V, - OUTDATED_LOG_MESSAGE, PLATFORMS, ) from .data import ProtectData, UFPConfigEntry @@ -58,10 +61,6 @@ SCAN_INTERVAL = timedelta(seconds=DEVICE_UPDATE_INTERVAL) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -EARLY_ACCESS_URL = ( - "https://www.home-assistant.io/integrations/unifiprotect#software-support" -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" @@ -73,6 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" + protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") @@ -93,6 +93,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: bootstrap = protect.bootstrap nvr_info = bootstrap.nvr auth_user = bootstrap.users.get(bootstrap.auth_user_id) + + # Check if API key is missing + if not protect.is_api_key_set() and auth_user and nvr_info.can_write(auth_user): + try: + new_api_key = await protect.create_api_key( + name=f"Home Assistant ({hass.config.location_name})" + ) + except (NotAuthorized, BadRequest) as err: + _LOGGER.error("Failed to create API key: %s", err) + else: + protect.set_api_key(new_api_key) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_API_KEY: new_api_key} + ) + + if not protect.is_api_key_set(): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_key_required", + ) + if auth_user and auth_user.cloud_account: ir.async_create_issue( hass, @@ -107,63 +128,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: ) if nvr_info.version < MIN_REQUIRED_PROTECT_V: - _LOGGER.error( - OUTDATED_LOG_MESSAGE, - nvr_info.version, - MIN_REQUIRED_PROTECT_V, + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="protect_version", + translation_placeholders={ + "current_version": str(nvr_info.version), + "min_version": str(MIN_REQUIRED_PROTECT_V), + }, ) - return False if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) entry.runtime_data = data_service - entry.async_on_unload(entry.add_update_listener(_async_options_updated)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) ) - if not entry.options.get(CONF_ALLOW_EA, False) and ( - await nvr_info.get_is_prerelease() - or nvr_info.release_channel != FirmwareReleaseChannel.RELEASE - ): - ir.async_create_issue( - hass, - DOMAIN, - "ea_channel_warning", - is_fixable=True, - is_persistent=False, - learn_more_url=EARLY_ACCESS_URL, - severity=IssueSeverity.WARNING, - translation_key="ea_channel_warning", - translation_placeholders={"version": str(nvr_info.version)}, - data={"entry_id": entry.entry_id}, - ) - - try: - await _async_setup_entry(hass, entry, data_service, bootstrap) - except Exception as err: - if await nvr_info.get_is_prerelease(): - # If they are running a pre-release, its quite common for setup - # to fail so we want to create a repair issue for them so its - # obvious what the problem is. - ir.async_create_issue( - hass, - DOMAIN, - f"ea_setup_failed_{nvr_info.version}", - is_fixable=False, - is_persistent=False, - learn_more_url="https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", - severity=IssueSeverity.ERROR, - translation_key="ea_setup_failed", - translation_placeholders={ - "error": str(err), - "version": str(nvr_info.version), - }, - ) - ir.async_delete_issue(hass, DOMAIN, "ea_channel_warning") - _LOGGER.exception("Error setting up UniFi Protect integration") - raise + await _async_setup_entry(hass, entry, data_service, bootstrap) return True @@ -183,11 +165,6 @@ async def _async_setup_entry( hass.http.register_view(VideoEventProxyView(hass)) -async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Unload UniFi Protect config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): @@ -211,3 +188,23 @@ async def async_remove_config_entry_device( if device.is_adopted_by_us and device.mac in unifi_macs: return False return True + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating configuration from version %s", entry.version) + + if entry.version > 1: + return False + + if entry.version == 1: + options = dict(entry.options) + if CONF_ALLOW_EA in options: + options.pop(CONF_ALLOW_EA) + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), version=2, options=options + ) + + _LOGGER.debug("Migration to configuration version %s successful", entry.version) + + return True diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 3947324fd73..aa05ec70dd0 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -247,7 +247,7 @@ class ProtectCamera(ProtectDeviceEntity, Camera): if self.channel.is_package: last_image = await self.device.get_package_snapshot(width, height) else: - last_image = await self.device.get_snapshot(width, height) + last_image = await self.device.get_public_api_snapshot() self._last_image = last_image return self._last_image diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 22af2fb135d..0eab326d609 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -20,9 +20,10 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_ID, CONF_PASSWORD, @@ -44,7 +45,6 @@ from homeassistant.util.network import is_ip_address from .const import ( CONF_ALL_UPDATES, - CONF_ALLOW_EA, CONF_DISABLE_RTSP, CONF_MAX_MEDIA, CONF_OVERRIDE_CHOST, @@ -215,6 +215,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -224,7 +225,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -238,7 +239,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_ALL_UPDATES: False, CONF_OVERRIDE_CHOST: False, CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA, - CONF_ALLOW_EA: False, }, ) @@ -249,6 +249,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): session = async_create_clientsession( self.hass, cookie_jar=CookieJar(unsafe=True) ) + public_api_session = async_get_clientsession(self.hass) host = user_input[CONF_HOST] port = user_input.get(CONF_PORT, DEFAULT_PORT) @@ -256,10 +257,12 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): protect = ProtectApiClient( session=session, + public_api_session=public_api_session, host=host, port=port, username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], + api_key=user_input[CONF_API_KEY], verify_ssl=verify_ssl, cache_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), config_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), @@ -274,7 +277,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug(ex) errors[CONF_PASSWORD] = "invalid_auth" except ClientError as ex: - _LOGGER.debug(ex) + _LOGGER.error(ex) errors["base"] = "cannot_connect" else: if nvr_data.version < MIN_REQUIRED_PROTECT_V: @@ -288,6 +291,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): auth_user = bootstrap.users.get(bootstrap.auth_user_id) if auth_user and auth_user.cloud_account: errors["base"] = "cloud_user" + try: + await protect.get_meta_info() + except NotAuthorized as ex: + _LOGGER.debug(ex) + errors[CONF_API_KEY] = "invalid_auth" + except ClientError as ex: + _LOGGER.error(ex) + errors["base"] = "cannot_connect" return nvr_data, errors @@ -320,12 +331,18 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): } return self.async_show_form( step_id="reauth_confirm", + description_placeholders={ + "local_user_documentation_url": await async_local_user_documentation_url( + self.hass + ), + }, data_schema=vol.Schema( { vol.Required( CONF_USERNAME, default=form_data.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -368,13 +385,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( @@ -408,10 +426,6 @@ class OptionsFlowHandler(OptionsFlow): CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA ), ): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)), - vol.Optional( - CONF_ALLOW_EA, - default=self.config_entry.options.get(CONF_ALLOW_EA, False), - ): bool, } ), ) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index d041b713125..f7138c24341 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -52,7 +52,7 @@ DEVICES_THAT_ADOPT = { DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} -MIN_REQUIRED_PROTECT_V = Version("1.20.0") +MIN_REQUIRED_PROTECT_V = Version("6.0.0") OUTDATED_LOG_MESSAGE = ( "You are running v%s of UniFi Protect. Minimum required version is v%s. Please" " upgrade UniFi Protect and then retry" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index baecc7f8323..1c03febe74b 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -93,12 +93,12 @@ class ProtectData: @property def disable_stream(self) -> bool: """Check if RTSP is disabled.""" - return self._entry.options.get(CONF_DISABLE_RTSP, False) + return self._entry.options.get(CONF_DISABLE_RTSP, False) # type: ignore[no-any-return] @property def max_events(self) -> int: """Max number of events to load at once.""" - return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) + return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) # type: ignore[no-any-return] @callback def async_subscribe_adopt( diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 47e2a01e798..50bdeec8572 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.14.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.21.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 020da0a03f6..8f24d9046ae 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -6,7 +6,6 @@ from typing import cast from uiprotect import ProtectApiClient from uiprotect.data import Bootstrap, Camera, ModelType -from uiprotect.data.types import FirmwareReleaseChannel import voluptuous as vol from homeassistant import data_entry_flow @@ -15,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir -from .const import CONF_ALLOW_EA from .data import UFPConfigEntry, async_get_data_for_entry_id from .utils import async_create_api_client @@ -45,52 +43,6 @@ class ProtectRepair(RepairsFlow): return description_placeholders -class EAConfirmRepair(ProtectRepair): - """Handler for an issue fixing flow.""" - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the first step of a fix flow.""" - - return await self.async_step_start() - - async def async_step_start( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is None: - placeholders = self._async_get_placeholders() - return self.async_show_form( - step_id="start", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - nvr = await self._api.get_nvr() - if nvr.release_channel != FirmwareReleaseChannel.RELEASE: - return await self.async_step_confirm() - await self.hass.config_entries.async_reload(self._entry.entry_id) - return self.async_create_entry(data={}) - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is not None: - options = dict(self._entry.options) - options[CONF_ALLOW_EA] = True - self.hass.config_entries.async_update_entry(self._entry, options=options) - return self.async_create_entry(data={}) - - placeholders = self._async_get_placeholders() - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - class CloudAccountRepair(ProtectRepair): """Handler for an issue fixing flow.""" @@ -242,8 +194,6 @@ async def async_create_fix_flow( and (entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"]))) ): api = _async_get_or_create_api_client(hass, entry) - if issue_id == "ea_channel_warning": - return EAConfirmRepair(api=api, entry=entry) if issue_id == "cloud_user": return CloudAccountRepair(api=api, entry=entry) if issue_id.startswith("rtsp_disabled_"): diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 40fe0a991f2..5a3dcc6ddfd 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -26,7 +26,10 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from homeassistant.util.json import JsonValueType from homeassistant.util.read_only_dict import ReadOnlyDict @@ -57,43 +60,31 @@ ALL_GLOBAL_SERIVCES = [ SERVICE_GET_USER_KEYRING_INFO, ] -DOORBELL_TEXT_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - vol.Required(ATTR_MESSAGE): cv.string, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +DOORBELL_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_MESSAGE): cv.string, + }, ) -CHIME_PAIRED_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - "doorbells": cv.TARGET_SERVICE_FIELDS, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +CHIME_PAIRED_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + "doorbells": cv.ENTITY_SERVICE_FIELDS, + }, ) -REMOVE_PRIVACY_ZONE_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - vol.Required(ATTR_NAME): cv.string, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +REMOVE_PRIVACY_ZONE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_NAME): cv.string, + }, ) -GET_USER_KEYRING_INFO_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +GET_USER_KEYRING_INFO_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + }, ) @@ -115,7 +106,7 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl @callback def _async_get_ufp_camera(call: ServiceCall) -> Camera: - ref = async_extract_referenced_entity_ids(call.hass, call) + ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data)) entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() @@ -133,7 +124,7 @@ def _async_get_protect_from_call(call: ServiceCall) -> set[ProtectApiClient]: return { _async_get_ufp_instance(call.hass, device_id) for device_id in async_extract_referenced_entity_ids( - call.hass, call + call.hass, TargetSelectorData(call.data) ).referenced_devices } @@ -196,7 +187,7 @@ def _async_unique_id_to_mac(unique_id: str) -> str: async def set_chime_paired_doorbells(call: ServiceCall) -> None: """Set paired doorbells on chime.""" - ref = async_extract_referenced_entity_ids(call.hass, call) + ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data)) entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() @@ -211,7 +202,9 @@ async def set_chime_paired_doorbells(call: ServiceCall) -> None: assert chime is not None call.data = ReadOnlyDict(call.data.get("doorbells") or {}) - doorbell_refs = async_extract_referenced_entity_ids(call.hass, call) + doorbell_refs = async_extract_referenced_entity_ids( + call.hass, TargetSelectorData(call.data) + ) doorbell_ids: set[str] = set() for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced: doorbell_sensor = entity_registry.async_get(camera_id) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 46a60f4abfd..9289d0f66d4 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -10,19 +10,27 @@ "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "host": "Hostname or IP address of your UniFi Protect device." + "host": "Hostname or IP address of your UniFi Protect device.", + "api_key": "API key for your local user account." } }, "reauth_confirm": { "title": "UniFi Protect reauth", + "description": "Your credentials or API key seem to be missing or invalid. For instructions on how to create a local user or generate a new API key, please refer to the documentation: {local_user_documentation_url}", "data": { "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account.", + "username": "Username for your local (not cloud) user account." } }, "discovery_confirm": { @@ -30,14 +38,18 @@ "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account." } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", + "protect_version": "Minimum required version is v6.0.0. Please upgrade UniFi Protect and then retry.", "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { @@ -55,32 +67,12 @@ "disable_rtsp": "Disable the RTSP stream", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", "override_connection_host": "Override connection host", - "max_media": "Max number of event to load for Media Browser (increases RAM usage)", - "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" + "max_media": "Max number of event to load for Media Browser (increases RAM usage)" } } } }, "issues": { - "ea_channel_warning": { - "title": "UniFi Protect Early Access enabled", - "fix_flow": { - "step": { - "start": { - "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." - }, - "confirm": { - "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", - "description": "Are you sure you want to run unsupported versions of UniFi Protect? This may cause your Home Assistant integration to break." - } - } - } - }, - "ea_setup_failed": { - "title": "Setup error using Early Access version", - "description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please restore a backup of a stable release of UniFi Protect to continue using the integration.\n\nError: {error}" - }, "cloud_user": { "title": "Ubiquiti Cloud Users are not Supported", "fix_flow": { @@ -365,7 +357,7 @@ "name": "Link speed" }, "wifi_signal_strength": { - "name": "WiFi signal strength" + "name": "Wi-Fi signal strength" }, "oldest_recording": { "name": "Oldest recording" @@ -689,5 +681,13 @@ } } } + }, + "exceptions": { + "api_key_required": { + "message": "API key is required. Please reauthenticate this integration to provide an API key." + }, + "protect_version": { + "message": "Your UniFi Protect version ({current_version}) is too old. Minimum required: {min_version}." + } } } diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 61314346d32..9071a24eae6 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -110,13 +110,16 @@ def async_create_api_client( """Create ProtectApiClient from config entry.""" session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + public_api_session = async_create_clientsession(hass) return ProtectApiClient( host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], + api_key=entry.data.get("api_key"), verify_ssl=entry.data[CONF_VERIFY_SSL], session=session, + public_api_session=public_api_session, subscribed_models=DEVICES_FOR_SUBSCRIBE, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 62ee4ede7d9..825c5774c1d 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/strings.json b/homeassistant/components/upnp/strings.json index bb414fa95f8..750cffaf1e2 100644 --- a/homeassistant/components/upnp/strings.json +++ b/homeassistant/components/upnp/strings.json @@ -21,7 +21,6 @@ "step": { "init": { "data": { - "scan_interval": "Update interval (seconds, minimal 30)", "force_poll": "Force polling of all data" } } diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000..cdeae16cc5a --- /dev/null +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -0,0 +1,74 @@ +"""The Uptime Kuma integration.""" + +from __future__ import annotations + +from pythonkuma.update import UpdateChecker + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN +from .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) + +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] + +UPTIME_KUMA_KEY: HassKey[UptimeKumaSoftwareUpdateCoordinator] = HassKey(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Set up Uptime Kuma from a config entry.""" + if UPTIME_KUMA_KEY not in hass.data: + session = async_get_clientsession(hass) + update_checker = UpdateChecker(session) + + update_coordinator = UptimeKumaSoftwareUpdateCoordinator(hass, update_checker) + await update_coordinator.async_request_refresh() + + hass.data[UPTIME_KUMA_KEY] = update_coordinator + + coordinator = UptimeKumaDataUpdateCoordinator(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_remove_config_entry_device( + hass: HomeAssistant, + config_entry: UptimeKumaConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a stale device from a config entry.""" + + def normalize_key(id: str) -> int | str: + key = id.removeprefix(f"{config_entry.entry_id}_") + return int(key) if key.isnumeric() else key + + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and ( + identifier[1] == config_entry.entry_id + or normalize_key(identifier[1]) in config_entry.runtime_data.data + ) + ) + + +async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + if not hass.config_entries.async_loaded_entries(DOMAIN): + await hass.data[UPTIME_KUMA_KEY].async_shutdown() + hass.data.pop(UPTIME_KUMA_KEY) + return unload_ok diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py new file mode 100644 index 00000000000..a6429ea7dfe --- /dev/null +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -0,0 +1,231 @@ +"""Config flow for the Uptime Kuma integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pythonkuma import ( + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Optional(CONF_API_KEY, default=""): str, + } +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str}) + + +async def validate_connection( + hass: HomeAssistant, + url: URL | str, + verify_ssl: bool, + api_key: str | None, +) -> dict[str, str]: + """Validate Uptime Kuma connectivity.""" + errors: dict[str, str] = {} + session = async_get_clientsession(hass, verify_ssl) + uptime_kuma = UptimeKuma(session, url, api_key) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + + +class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Uptime Kuma.""" + + _hassio_discovery: HassioServiceInfo | 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: + url = URL(user_input[CONF_URL]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): + return self.async_create_entry( + title=url.host or "", + data={**user_input, CONF_URL: url.human_repr()}, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + if not ( + errors := await validate_connection( + self.hass, + entry.data[CONF_URL], + entry.data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): + return self.async_update_reload_and_abort( + entry, + data_updates={**user_input, CONF_URL: url.human_repr()}, + ) + + 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 entry.data, + ), + errors=errors, + ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for Uptime Kuma add-on. + + This flow is triggered by the discovery component. + """ + self._async_abort_entries_match({CONF_URL: discovery_info.config[CONF_URL]}) + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured( + updates={CONF_URL: discovery_info.config[CONF_URL]} + ) + + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery.""" + assert self._hassio_discovery + errors: dict[str, str] = {} + api_key = user_input[CONF_API_KEY] if user_input else None + + if not ( + errors := await validate_connection( + self.hass, + self._hassio_discovery.config[CONF_URL], + True, + api_key, + ) + ): + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={ + "addon": self._hassio_discovery.config["addon"] + }, + ) + return self.async_create_entry( + title=self._hassio_discovery.slug, + data={ + CONF_URL: self._hassio_discovery.config[CONF_URL], + CONF_VERIFY_SSL: True, + CONF_API_KEY: api_key, + }, + ) + + return self.async_show_form( + step_id="hassio_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + description_placeholders={"addon": self._hassio_discovery.config["addon"]}, + errors=errors if user_input is not None else None, + ) diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py new file mode 100644 index 00000000000..2bd4b1f9165 --- /dev/null +++ b/homeassistant/components/uptime_kuma/const.py @@ -0,0 +1,26 @@ +"""Constants for the Uptime Kuma integration.""" + +from pythonkuma import MonitorType + +DOMAIN = "uptime_kuma" + +HAS_CERT = { + MonitorType.HTTP, + MonitorType.KEYWORD, + MonitorType.JSON_QUERY, +} +HAS_URL = HAS_CERT | {MonitorType.REAL_BROWSER} +HAS_PORT = { + MonitorType.PORT, + MonitorType.STEAM, + MonitorType.GAMEDIG, + MonitorType.MQTT, + MonitorType.RADIUS, + MonitorType.SNMP, + MonitorType.SMTP, +} +HAS_HOST = HAS_PORT | { + MonitorType.PING, + MonitorType.TAILSCALE_PING, + MonitorType.DNS, +} diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py new file mode 100644 index 00000000000..df64b12f8e9 --- /dev/null +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -0,0 +1,142 @@ +"""Coordinator for the Uptime Kuma integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pythonkuma import ( + UpdateException, + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, + UptimeKumaMonitor, + UptimeKumaVersion, +) +from pythonkuma.update import LatestRelease, UpdateChecker + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +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 DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL_UPDATES = timedelta(hours=3) + +type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] + + +class UptimeKumaDataUpdateCoordinator( + DataUpdateCoordinator[dict[str | int, UptimeKumaMonitor]] +): + """Update coordinator for Uptime Kuma.""" + + config_entry: UptimeKumaConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: UptimeKumaConfigEntry + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) + self.api = UptimeKuma( + session, config_entry.data[CONF_URL], config_entry.data[CONF_API_KEY] + ) + self.version: UptimeKumaVersion | None = None + + async def _async_update_data(self) -> dict[str | int, UptimeKumaMonitor]: + """Fetch the latest data from Uptime Kuma.""" + + try: + metrics = await self.api.metrics() + except UptimeKumaAuthenticationException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed_exception", + ) from e + except UptimeKumaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_failed_exception", + ) from e + else: + async_migrate_entities_unique_ids(self.hass, self, metrics) + self.version = self.api.version + + return metrics + + +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, + coordinator: UptimeKumaDataUpdateCoordinator, + metrics: dict[str | int, UptimeKumaMonitor], +) -> None: + """Migrate unique_ids in the entity registry after updating Uptime Kuma.""" + + if ( + coordinator.version is coordinator.api.version + or int(coordinator.api.version.major) < 2 + ): + return + + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, coordinator.config_entry.entry_id + ) + + for registry_entry in registry_entries: + name = registry_entry.unique_id.removeprefix( + f"{registry_entry.config_entry_id}_" + ).removesuffix(f"_{registry_entry.translation_key}") + if monitor := next( + ( + m + for m in metrics.values() + if m.monitor_name == name and m.monitor_id is not None + ), + None, + ): + entity_registry.async_update_entity( + registry_entry.entity_id, + new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", + ) + + +class UptimeKumaSoftwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): + """Uptime Kuma coordinator for retrieving update information.""" + + def __init__(self, hass: HomeAssistant, update_checker: UpdateChecker) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=DOMAIN, + update_interval=SCAN_INTERVAL_UPDATES, + ) + self.update_checker = update_checker + + async def _async_update_data(self) -> LatestRelease: + """Fetch data.""" + try: + return await self.update_checker.latest_release() + except UpdateException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/uptime_kuma/diagnostics.py b/homeassistant/components/uptime_kuma/diagnostics.py new file mode 100644 index 00000000000..48e23adc40d --- /dev/null +++ b/homeassistant/components/uptime_kuma/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics platform for Uptime Kuma.""" + +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 UptimeKumaConfigEntry + +TO_REDACT = {"monitor_url", "monitor_hostname"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: UptimeKumaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return async_redact_data( + {k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT + ) diff --git a/homeassistant/components/uptime_kuma/icons.json b/homeassistant/components/uptime_kuma/icons.json new file mode 100644 index 00000000000..73f5fd63661 --- /dev/null +++ b/homeassistant/components/uptime_kuma/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "sensor": { + "cert_days_remaining": { + "default": "mdi:certificate" + }, + "response_time": { + "default": "mdi:timeline-clock-outline" + }, + "status": { + "default": "mdi:lan-connect", + "state": { + "down": "mdi:lan-disconnect", + "pending": "mdi:lan-pending", + "maintenance": "mdi:account-hard-hat-outline" + } + }, + "type": { + "default": "mdi:protocol" + }, + "url": { + "default": "mdi:web" + }, + "hostname": { + "default": "mdi:ip-outline" + }, + "port": { + "default": "mdi:ip-outline" + } + } + } +} diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json new file mode 100644 index 00000000000..6ea7150f15d --- /dev/null +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "uptime_kuma", + "name": "Uptime Kuma", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/uptime_kuma", + "iot_class": "cloud_polling", + "loggers": ["pythonkuma"], + "quality_scale": "platinum", + "requirements": ["pythonkuma==0.3.1"] +} diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml new file mode 100644 index 00000000000..56274d868ae --- /dev/null +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: integration has no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: integration has no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: integration has no 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 has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: integration has no 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: done + discovery-update-info: done + discovery: + status: done + comment: hassio addon supports discovery, other installation methods are not discoverable + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: integration is a service + 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: has no repairs + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py new file mode 100644 index 00000000000..b499c67da16 --- /dev/null +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -0,0 +1,182 @@ +"""Sensor platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from pythonkuma import MonitorType, UptimeKumaMonitor +from pythonkuma.models import MonitorStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_URL, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +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, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL +from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +class UptimeKumaSensor(StrEnum): + """Uptime Kuma sensors.""" + + CERT_DAYS_REMAINING = "cert_days_remaining" + RESPONSE_TIME = "response_time" + STATUS = "status" + TYPE = "type" + URL = "url" + HOSTNAME = "hostname" + PORT = "port" + + +@dataclass(kw_only=True, frozen=True) +class UptimeKumaSensorEntityDescription(SensorEntityDescription): + """Uptime Kuma sensor description.""" + + value_fn: Callable[[UptimeKumaMonitor], StateType] + create_entity: Callable[[MonitorType], bool] + + +SENSOR_DESCRIPTIONS: tuple[UptimeKumaSensorEntityDescription, ...] = ( + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.CERT_DAYS_REMAINING, + translation_key=UptimeKumaSensor.CERT_DAYS_REMAINING, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda m: m.monitor_cert_days_remaining, + create_entity=lambda t: t in HAS_CERT, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.RESPONSE_TIME, + translation_key=UptimeKumaSensor.RESPONSE_TIME, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + value_fn=( + lambda m: m.monitor_response_time if m.monitor_response_time > -1 else None + ), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.STATUS, + translation_key=UptimeKumaSensor.STATUS, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorStatus], + value_fn=lambda m: m.monitor_status.name.lower(), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.TYPE, + translation_key=UptimeKumaSensor.TYPE, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorType], + value_fn=lambda m: m.monitor_type.name.lower(), + entity_category=EntityCategory.DIAGNOSTIC, + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.URL, + translation_key=UptimeKumaSensor.URL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_url, + create_entity=lambda t: t in HAS_URL, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.HOSTNAME, + translation_key=UptimeKumaSensor.HOSTNAME, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_hostname, + create_entity=lambda t: t in HAS_HOST, + ), + 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, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + monitor_added: set[str | int] = set() + + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal monitor_added + + if new_monitor := set(coordinator.data.keys()) - monitor_added: + async_add_entities( + UptimeKumaSensorEntity(coordinator, monitor, description) + for description in SENSOR_DESCRIPTIONS + for monitor in new_monitor + if description.create_entity(coordinator.data[monitor].monitor_type) + ) + monitor_added |= new_monitor + + coordinator.async_add_listener(add_entities) + add_entities() + + +class UptimeKumaSensorEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], SensorEntity +): + """An Uptime Kuma sensor entity.""" + + entity_description: UptimeKumaSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + monitor: str | int, + entity_description: UptimeKumaSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + self.monitor = monitor + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{monitor!s}_{entity_description.key}" + ) + 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 + ), + sw_version=coordinator.api.version.version, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data[self.monitor]) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.monitor in self.coordinator.data diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json new file mode 100644 index 00000000000..e84b68501f3 --- /dev/null +++ b/homeassistant/components/uptime_kuma/strings.json @@ -0,0 +1,137 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up **Uptime Kuma** monitoring service", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "Enter the full URL of your Uptime Kuma instance. Be sure to include the protocol (`http` or `https`), the hostname or IP address, the port number (if it is a non-default port), and any path prefix if applicable. Example: `https://uptime.example.com`", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", + "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" + } + }, + "reauth_confirm": { + "title": "Re-authenticate with Uptime Kuma: {name}", + "description": "The API key for **{name}** is invalid. To re-authenticate with Uptime Kuma provide a new API key below", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } + }, + "reconfigure": { + "title": "Update configuration for Uptime Kuma", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::uptime_kuma::config::step::user::data_description::url%]", + "verify_ssl": "[%key:component::uptime_kuma::config::step::user::data_description::verify_ssl%]", + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } + }, + "hassio_confirm": { + "title": "Uptime Kuma via Home Assistant add-on", + "description": "Do you want to configure Home Assistant to connect to the Uptime Kuma service provided by the add-on: {addon}?", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } + } + }, + "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%]" + }, + "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%]" + } + }, + "entity": { + "sensor": { + "cert_days_remaining": { + "name": "Certificate expiry" + }, + "response_time": { + "name": "Response time" + }, + "status": { + "name": "Status", + "state": { + "up": "Up", + "down": "Down", + "pending": "Pending", + "maintenance": "Maintenance" + } + }, + "type": { + "name": "Monitor type", + "state": { + "http": "HTTP(s)", + "port": "TCP port", + "ping": "Ping", + "keyword": "HTTP(s) - Keyword", + "dns": "DNS", + "push": "Push", + "steam": "Steam Game Server", + "mqtt": "MQTT", + "sqlserver": "Microsoft SQL Server", + "json_query": "HTTP(s) - JSON query", + "group": "Group", + "docker": "Docker", + "grpc_keyword": "gRPC(s) - Keyword", + "real_browser": "HTTP(s) - Browser engine", + "gamedig": "GameDig", + "kafka_producer": "Kafka Producer", + "postgres": "PostgreSQL", + "mysql": "MySQL/MariaDB", + "mongodb": "MongoDB", + "radius": "Radius", + "redis": "Redis", + "tailscale_ping": "Tailscale Ping", + "snmp": "SNMP", + "smtp": "SMTP", + "rabbit_mq": "RabbitMQ", + "manual": "Manual" + } + }, + "url": { + "name": "Monitored URL" + }, + "hostname": { + "name": "Monitored hostname" + }, + "port": { + "name": "Monitored port" + } + }, + "update": { + "update": { + "name": "Uptime Kuma version" + } + } + }, + "exceptions": { + "auth_failed_exception": { + "message": "Authentication with Uptime Kuma failed. Please check that your API key is correct and still valid" + }, + "request_failed_exception": { + "message": "Connection to Uptime Kuma failed" + }, + "update_check_failed": { + "message": "Failed to check for latest Uptime Kuma update" + } + } +} diff --git a/homeassistant/components/uptime_kuma/update.py b/homeassistant/components/uptime_kuma/update.py new file mode 100644 index 00000000000..6fe4e477f0b --- /dev/null +++ b/homeassistant/components/uptime_kuma/update.py @@ -0,0 +1,122 @@ +"""Update platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import CONF_URL +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 .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) + +PARALLEL_UPDATES = 0 + + +class UptimeKumaUpdate(StrEnum): + """Uptime Kuma update.""" + + UPDATE = "update" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up update platform.""" + + coordinator = entry.runtime_data + async_add_entities( + [UptimeKumaUpdateEntity(coordinator, hass.data[UPTIME_KUMA_KEY])] + ) + + +class UptimeKumaUpdateEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], UpdateEntity +): + """Representation of an update entity.""" + + entity_description = UpdateEntityDescription( + key=UptimeKumaUpdate.UPDATE, + translation_key=UptimeKumaUpdate.UPDATE, + ) + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + update_coordinator: UptimeKumaSoftwareUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.update_checker = update_coordinator + + 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], + sw_version=coordinator.api.version.version, + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{self.entity_description.key}" + ) + + @property + def installed_version(self) -> str | None: + """Current version.""" + + return self.coordinator.api.version.version + + @property + def title(self) -> str | None: + """Title of the release.""" + + return f"Uptime Kuma {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 + + 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 software update 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/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 64fa3342c08..8a388058b19 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,7 +21,10 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -199,6 +202,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Utility Meter from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE_SENSOR] ) @@ -225,20 +229,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -286,13 +286,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False if config_entry.version == 1: new = {**config_entry.options} new[CONF_METER_PERIODICALLY_RESETTING] = True hass.config_entries.async_update_entry(config_entry, options=new, version=2) - _LOGGER.info("Migration to version %s successful", config_entry.version) + if config_entry.version == 2: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the utility_meter config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + 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/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index e8acca88cbe..933a04accba 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -94,6 +94,7 @@ CONFIG_SCHEMA = vol.Schema( max=28, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="days", + translation_key=CONF_METER_OFFSET, ), ), vol.Required(CONF_TARIFFS, default=[]): selector.SelectSelector( @@ -129,6 +130,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" VERSION = 2 + MINOR_VERSION = 2 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 0c818525c8d..280a1fd7b1a 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -8,8 +8,8 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -33,7 +33,7 @@ async def async_setup_entry( unique_id = config_entry.entry_id - device_info = async_device_info_to_link_from_entity( + device = async_entity_id_to_device( hass, config_entry.options[CONF_SOURCE_SENSOR], ) @@ -42,7 +42,7 @@ async def async_setup_entry( name=name, tariffs=tariffs, unique_id=unique_id, - device_info=device_info, + device=device, ) async_add_entities([tariff_select]) @@ -91,14 +91,14 @@ class TariffSelect(SelectEntity, RestoreEntity): *, yaml_slug: str | None = None, unique_id: str | None = None, - device_info: DeviceInfo | None = None, + device: DeviceEntry | None = None, ) -> None: """Initialize a tariff selector.""" self._attr_name = name if yaml_slug: # Backwards compatibility with YAML configuration entries self.entity_id = f"select.{yaml_slug}" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = device self._current_tariff: str | None = None self._tariffs = tariffs self._attr_should_poll = False diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index d424692ac95..457b02c2b50 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -39,7 +39,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -129,11 +129,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - cron_pattern = None delta_values = config_entry.options[CONF_METER_DELTA_VALUES] meter_offset = timedelta(days=config_entry.options[CONF_METER_OFFSET]) @@ -154,6 +149,7 @@ async def async_setup_entry( if not tariffs: # Add single sensor, not gated by a tariff selector meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -166,7 +162,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=None, unique_id=entry_id, - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -175,6 +170,7 @@ async def async_setup_entry( # Add sensors for each tariff for tariff in tariffs: meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -187,7 +183,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=tariff, unique_id=f"{entry_id}_{tariff}", - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -259,6 +254,7 @@ async def async_setup_platform( CONF_SENSOR_ALWAYS_AVAILABLE ] meter_sensor = UtilityMeterSensor( + hass, cron_pattern=conf_cron_pattern, delta_values=conf_meter_delta_values, meter_offset=conf_meter_offset, @@ -359,6 +355,7 @@ class UtilityMeterSensor(RestoreSensor): def __init__( self, + hass, *, cron_pattern, delta_values, @@ -374,11 +371,13 @@ class UtilityMeterSensor(RestoreSensor): unique_id, sensor_always_available, suggested_entity_id=None, - device_info=None, ): """Initialize the Utility Meter sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self.entity_id = suggested_entity_id self._parent_meter = parent_meter self._sensor_source_id = source_entity diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index aadc0f82412..0ba7ad85050 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -58,6 +58,11 @@ "quarterly": "Quarterly", "yearly": "Yearly" } + }, + "offset": { + "unit_of_measurement": { + "days": "days" + } } }, "services": { diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 83c68fb61b6..081b7a15995 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -79,6 +79,11 @@ DEFAULT_NAME = "Vacuum cleaner robot" _DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") _DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") +_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ( + "mqtt", + "template", +) + class VacuumEntityFeature(IntFlag): """Supported features of the vacuum entity.""" @@ -247,6 +252,9 @@ class StateVacuumEntity( _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) __vacuum_legacy_state: bool = False + __vacuum_legacy_battery_level: bool = False + __vacuum_legacy_battery_icon: bool = False + __vacuum_legacy_battery_feature: bool = False def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" @@ -255,15 +263,28 @@ class StateVacuumEntity( # Integrations should use the 'activity' property instead of # setting the state directly. cls.__vacuum_legacy_state = True + if any( + method in cls.__dict__ + for method in ("_attr_battery_level", "battery_level") + ): + # Integrations should use a separate battery sensor. + cls.__vacuum_legacy_battery_level = True + if any( + method in cls.__dict__ for method in ("_attr_battery_icon", "battery_icon") + ): + # Integrations should use a separate battery sensor. + cls.__vacuum_legacy_battery_icon = True def __setattr__(self, name: str, value: Any) -> None: """Set attribute. - Deprecation warning if setting '_attr_state' directly - unless already reported. + Deprecation warning if setting state, battery icon or battery level + attributes directly unless already reported. """ if name == "_attr_state": self._report_deprecated_activity_handling() + if name in {"_attr_battery_level", "_attr_battery_icon"}: + self._report_deprecated_battery_properties(name[6:]) return super().__setattr__(name, value) @callback @@ -277,6 +298,10 @@ class StateVacuumEntity( super().add_to_platform_start(hass, platform, parallel_updates) if self.__vacuum_legacy_state: self._report_deprecated_activity_handling() + if self.__vacuum_legacy_battery_level: + self._report_deprecated_battery_properties("battery_level") + if self.__vacuum_legacy_battery_icon: + self._report_deprecated_battery_properties("battery_icon") @callback def _report_deprecated_activity_handling(self) -> None: @@ -295,6 +320,54 @@ class StateVacuumEntity( exclude_integrations={DOMAIN}, ) + @callback + def _report_deprecated_battery_properties(self, property: str) -> None: + """Report on deprecated use of battery properties. + + Integrations should implement a sensor instead. + """ + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): + # Don't report usage until after entity added to hass, after init + report_usage( + f"is setting the {property} which has been deprecated." + f" Integration {self.platform.platform_name} should implement a sensor" + " instead with a correct device class and link it to the same device", + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.8", + integration_domain=self.platform.platform_name, + exclude_integrations={DOMAIN}, + ) + + @callback + def _report_deprecated_battery_feature(self) -> None: + """Report on deprecated use of battery supported features. + + Integrations should remove the battery supported feature when migrating + battery level and icon to a sensor. + """ + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): + # Don't report usage until after entity added to hass, after init + report_usage( + f"is setting the battery supported feature which has been deprecated." + f" Integration {self.platform.platform_name} should remove this as part of migrating" + " the battery level and icon to a sensor", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.8", + integration_domain=self.platform.platform_name, + exclude_integrations={DOMAIN}, + ) + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -333,6 +406,9 @@ class StateVacuumEntity( supported_features = self.supported_features if VacuumEntityFeature.BATTERY in supported_features: + if self.__vacuum_legacy_battery_feature is False: + self._report_deprecated_battery_feature() + self.__vacuum_legacy_battery_feature = True data[ATTR_BATTERY_LEVEL] = self.battery_level data[ATTR_BATTERY_ICON] = self.battery_icon diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 2a074cf2015..f12a5328330 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -34,7 +34,7 @@ "entity": { "binary_sensor": { "post_heater": { - "name": "Post heater" + "name": "Post-heater" } }, "number": { diff --git a/homeassistant/components/vegehub/strings.json b/homeassistant/components/vegehub/strings.json index aa9b3aad227..c35fe0d83c9 100644 --- a/homeassistant/components/vegehub/strings.json +++ b/homeassistant/components/vegehub/strings.json @@ -27,8 +27,8 @@ "cannot_connect": "Failed to connect to the device. Please try again.", "timeout_connect": "Timed out connecting. Ensure VegeHub is awake, and try again.", "already_in_progress": "Device already detected. Check discovered devices.", - "already_configured": "Device is already configured.", - "unknown_error": "An unknown error has occurred." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "unknown_error": "[%key:common::config_flow::error::unknown%]" } }, "entity": { diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 35c61892964..055fd5e2277 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .services import setup_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions for the Velbus component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index f42e449bdcc..7223e83ddf4 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ( DOMAIN: Final = "velbus" CONF_CONFIG_ENTRY: Final = "config_entry" -CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" CONF_TLS: Final = "tls" diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 765c5a0f674..34d074c2dec 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -11,10 +11,9 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.storage import STORAGE_DIR if TYPE_CHECKING: @@ -22,7 +21,6 @@ if TYPE_CHECKING: from .const import ( CONF_CONFIG_ENTRY, - CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, SERVICE_CLEAR_CACHE, @@ -32,7 +30,8 @@ from .const import ( ) -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register the velbus services.""" def check_entry_id(interface: str) -> str: @@ -48,18 +47,6 @@ def setup_services(hass: HomeAssistant) -> None: """Get the config entry for this service call.""" if CONF_CONFIG_ENTRY in call.data: entry_id = call.data[CONF_CONFIG_ENTRY] - elif CONF_INTERFACE in call.data: - # Deprecated in 2025.2, to remove in 2025.8 - async_create_issue( - hass, - DOMAIN, - "deprecated_interface_parameter", - breaks_in_ha_version="2025.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_interface_parameter", - ) - entry_id = call.data[CONF_INTERFACE] if not (entry := hass.config_entries.async_get_entry(entry_id)): raise ServiceValidationError( translation_domain=DOMAIN, @@ -117,21 +104,14 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SCAN, scan, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ) - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } ), ) @@ -139,21 +119,14 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SYNC, syn_clock, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ) - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } ), ) @@ -161,29 +134,18 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SET_MEMO_TEXT, set_memo_text, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } ), ) @@ -191,26 +153,16 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_CLEAR_CACHE, clear_cache, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } ), ) diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index 39886913692..2e649c60289 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,10 +1,5 @@ sync_clock: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: @@ -12,11 +7,6 @@ sync_clock: scan: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: @@ -24,11 +14,6 @@ scan: clear_cache: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: @@ -42,11 +27,6 @@ clear_cache: set_memo_text: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 4ef7ccf62c2..82bcf5cdd5d 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -60,10 +60,6 @@ "name": "Sync clock", "description": "Syncs the clock of the Velbus modules to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { - "interface": { - "name": "Interface", - "description": "The Velbus interface to send the command to, this will be the same value as used during configuration." - }, "config_entry": { "name": "Config entry", "description": "The config entry of the Velbus integration" @@ -74,10 +70,6 @@ "name": "Scan", "description": "Scans the Velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", "fields": { - "interface": { - "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", - "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" - }, "config_entry": { "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" @@ -88,10 +80,6 @@ "name": "Clear cache", "description": "Clears the Velbus cache and then starts a new scan.", "fields": { - "interface": { - "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", - "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" - }, "config_entry": { "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" @@ -106,10 +94,6 @@ "name": "Set memo text", "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD. Be sure the pages of the modules are configured to display the memo text.", "fields": { - "interface": { - "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", - "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" - }, "config_entry": { "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py new file mode 100644 index 00000000000..e08d4bcf545 --- /dev/null +++ b/homeassistant/components/velux/binary_sensor.py @@ -0,0 +1,63 @@ +"""Support for rain sensors build into some velux windows.""" + +from __future__ import annotations + +from datetime import timedelta + +from pyvlx.exception import PyVLXException +from pyvlx.opening_device import OpeningDevice, Window + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, LOGGER +from .entity import VeluxEntity + +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up rain sensor(s) for Velux platform.""" + module = hass.data[DOMAIN][config.entry_id] + + async_add_entities( + VeluxRainSensor(node, config.entry_id) + for node in module.pyvlx.nodes + if isinstance(node, Window) and node.rain_sensor + ) + + +class VeluxRainSensor(VeluxEntity, BinarySensorEntity): + """Representation of a Velux rain sensor.""" + + node: Window + _attr_should_poll = True # the rain sensor / opening limitations needs polling unlike the rest of the Velux devices + _attr_entity_registry_enabled_default = False + _attr_device_class = BinarySensorDeviceClass.MOISTURE + + def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: + """Initialize VeluxRainSensor.""" + super().__init__(node, config_entry_id) + self._attr_unique_id = f"{self._attr_unique_id}_rain_sensor" + self._attr_name = f"{node.name} Rain sensor" + + async def async_update(self) -> None: + """Fetch the latest state from the device.""" + try: + limitation = await self.node.get_limitation() + except PyVLXException: + LOGGER.error("Error fetching limitation data for cover %s", self.name) + return + + # Velux windows with rain sensors report an opening limitation of 93 when rain is detected. + self._attr_is_on = limitation.min_value == 93 diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py index 49a762e87ca..46663383250 100644 --- a/homeassistant/components/velux/const.py +++ b/homeassistant/components/velux/const.py @@ -5,5 +5,5 @@ from logging import getLogger from homeassistant.const import Platform DOMAIN = "velux" -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE] LOGGER = getLogger(__package__) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index f3045fe49e8..5991dc8fe51 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/venstar", "iot_class": "local_polling", "loggers": ["venstarcolortouch"], - "requirements": ["venstarcolortouch==0.19"] + "requirements": ["venstarcolortouch==0.21"] } diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index b8f0b702ebe..aedc174cb6d 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -143,7 +143,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True @@ -161,11 +160,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def map_vera_device( vera_device: veraApi.VeraDevice, remap: list[int] ) -> Platform | None: diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index f2b182cc270..f02549e7857 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import callback @@ -73,7 +73,7 @@ def options_data(user_input: dict[str, str]) -> dict[str, list[int]]: ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py index b3013c288c1..985761f2e63 100644 --- a/homeassistant/components/vera/entity.py +++ b/homeassistant/components/vera/entity.py @@ -48,6 +48,10 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): """Subscribe to updates.""" self.controller.register(self.vera_device, self._update_callback) + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from updates.""" + self.controller.unregister(self.vera_device, self._update_callback) + def _update_callback(self, _device: _DeviceTypeT) -> None: """Update the state.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 08db4463e07..6d818b463d8 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -129,6 +129,7 @@ SKU_TO_BASE_DEVICE = { "LAP-V102S-WEU": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-WUK": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-AUSR": "Vital100S", # Alt ID Model Vital100S + "LAP-V102S-WJP": "Vital100S", # Alt ID Model Vital100S "EverestAir": "EverestAir", "LAP-EL551S-AUS": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index d9336552744..5b0197606ae 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -165,28 +165,36 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return attr def set_percentage(self, percentage: int) -> None: - """Set the speed of the device.""" + """Set the speed of the device. + + If percentage is 0, turn off the fan. Otherwise, ensure the fan is on, + set manual mode if needed, and set the speed. + """ + device_type = SKU_TO_BASE_DEVICE[self.device.device_type] + speed_range = SPEED_RANGE[device_type] + if percentage == 0: - success = self.device.turn_off() - if not success: + # Turning off is a special case: do not set speed or mode + if not self.device.turn_off(): raise HomeAssistantError("An error occurred while turning off.") - elif not self.device.is_on: - success = self.device.turn_on() - if not success: + self.schedule_update_ha_state() + return + + # If the fan is off, turn it on first + if not self.device.is_on: + if not self.device.turn_on(): raise HomeAssistantError("An error occurred while turning on.") - success = self.device.manual_mode() - if not success: - raise HomeAssistantError("An error occurred while manual mode.") - success = self.device.change_fan_speed( - math.ceil( - percentage_to_ranged_value( - SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage - ) - ) - ) - if not success: + # Switch to manual mode if not already set + if self.device.mode != VS_FAN_MODE_MANUAL: + if not self.device.manual_mode(): + raise HomeAssistantError("An error occurred while setting manual mode.") + + # Calculate the speed level and set it + speed_level = math.ceil(percentage_to_ranged_value(speed_range, percentage)) + if not self.device.change_fan_speed(speed_level): raise HomeAssistantError("An error occurred while changing fan speed.") + self.schedule_update_ha_state() def set_preset_mode(self, preset_mode: str) -> None: diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index fed777e6435..8e632e46efe 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.44.0"] + "requirements": ["PyViCare==2.50.0"] } diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 17b0fe6e501..0433199b54e 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -25,8 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -39,9 +37,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> await coordinator.api.logout() return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index c330a93a1a8..13e30d38926 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -180,7 +184,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): ) -class VodafoneStationOptionsFlowHandler(OptionsFlow): +class VodafoneStationOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" async def async_step_init( diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 57d39151160..35c32ab2af3 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -187,4 +187,5 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): model=sensors_data.get("sys_model_name"), hw_version=sensors_data["sys_hardware_version"], sw_version=sensors_data["sys_firmware_version"], + serial_number=self.serial_number, ) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index ac8065cabf7..8d11cf2ff89 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -364,6 +364,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if self._check_hangup_task is not None: self._check_hangup_task.cancel() self._check_hangup_task = None + self._rtp_port = None def connection_made(self, transport): """Server is ready.""" diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 59e54bfefea..fe855159d55 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.2"] + "requirements": ["voip-utils==0.3.4"] } diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py new file mode 100644 index 00000000000..c6632185f0a --- /dev/null +++ b/homeassistant/components/volvo/__init__.py @@ -0,0 +1,97 @@ +"""The Volvo integration.""" + +from __future__ import annotations + +import asyncio + +from aiohttp import ClientResponseError +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoAuthException, VolvoCarsVehicle + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import VolvoAuth +from .const import CONF_VIN, DOMAIN, PLATFORMS +from .coordinator import ( + VolvoConfigEntry, + VolvoMediumIntervalCoordinator, + VolvoSlowIntervalCoordinator, + VolvoVerySlowIntervalCoordinator, +) + + +async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Set up Volvo from a config entry.""" + + api = await _async_auth_and_create_api(hass, entry) + vehicle = await _async_load_vehicle(api) + + # Order is important! Faster intervals must come first. + coordinators = ( + VolvoMediumIntervalCoordinator(hass, entry, api, vehicle), + VolvoSlowIntervalCoordinator(hass, entry, api, vehicle), + VolvoVerySlowIntervalCoordinator(hass, entry, api, vehicle), + ) + + await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) + + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _async_auth_and_create_api( + hass: HomeAssistant, entry: VolvoConfigEntry +) -> VolvoCarsApi: + implementation = await async_get_config_entry_implementation(hass, entry) + oauth_session = OAuth2Session(hass, entry, implementation) + web_session = async_get_clientsession(hass) + auth = VolvoAuth(web_session, oauth_session) + + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if err.status in (400, 401): + raise ConfigEntryAuthFailed from err + + raise ConfigEntryNotReady from err + + return VolvoCarsApi( + web_session, + auth, + entry.data[CONF_API_KEY], + entry.data[CONF_VIN], + ) + + +async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle: + try: + vehicle = await api.async_get_vehicle_details() + except VolvoAuthException as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": ex.message}, + ) from ex + + if vehicle is None: + raise ConfigEntryError(translation_domain=DOMAIN, translation_key="no_vehicle") + + return vehicle diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py new file mode 100644 index 00000000000..e2c1070f1ea --- /dev/null +++ b/homeassistant/components/volvo/api.py @@ -0,0 +1,38 @@ +"""API for Volvo bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from volvocarsapi.auth import AccessTokenManager + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + + +class VolvoAuth(AccessTokenManager): + """Provide Volvo authentication tied to an OAuth2 based config entry.""" + + def __init__(self, websession: ClientSession, oauth_session: OAuth2Session) -> None: + """Initialize Volvo auth.""" + 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 ConfigFlowVolvoAuth(AccessTokenManager): + """Provide Volvo authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__(self, websession: ClientSession, token: str) -> None: + """Initialize ConfigFlowVolvoAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Volvo API.""" + return self._token diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py new file mode 100644 index 00000000000..18dae40f8ee --- /dev/null +++ b/homeassistant/components/volvo/application_credentials.py @@ -0,0 +1,37 @@ +"""Application credentials platform for the Volvo integration.""" + +from __future__ import annotations + +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL +from volvocarsapi.scopes import DEFAULT_SCOPES + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2ImplementationWithPkce, +) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> VolvoOAuth2Implementation: + """Return auth implementation for a custom auth implementation.""" + return VolvoOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + AUTHORIZE_URL, + TOKEN_URL, + credential.client_secret, + ) + + +class VolvoOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """Volvo oauth2 implementation.""" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": " ".join(DEFAULT_SCOPES), + } diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py new file mode 100644 index 00000000000..0ae0e54077e --- /dev/null +++ b/homeassistant/components/volvo/config_flow.py @@ -0,0 +1,247 @@ +"""Config flow for Volvo.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle +from volvocarsapi.scopes import DEFAULT_SCOPES + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .api import ConfigFlowVolvoAuth +from .const import CONF_VIN, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +def _create_volvo_cars_api( + hass: HomeAssistant, access_token: str, api_key: str +) -> VolvoCarsApi: + web_session = aiohttp_client.async_get_clientsession(hass) + auth = ConfigFlowVolvoAuth(web_session, access_token) + return VolvoCarsApi(web_session, auth, api_key) + + +class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Volvo OAuth2 authentication.""" + + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Initialize Volvo config flow.""" + super().__init__() + + self._vehicles: list[VolvoCarsVehicle] = [] + self._config_data: dict = {} + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": " ".join(DEFAULT_SCOPES), + } + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow.""" + self._config_data |= (self.init_data or {}) | data + return await self.async_step_api_key() + + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reconfigure( + self, data: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_api_key() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) + return await self.async_step_user() + + async def async_step_api_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the API key step.""" + errors: dict[str, str] = {} + + if user_input is not None: + api = _create_volvo_cars_api( + self.hass, + self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], + user_input[CONF_API_KEY], + ) + + # Try to load all vehicles on the account. If it succeeds + # it means that the given API key is correct. The vehicle info + # is used in the VIN step. + try: + await self._async_load_vehicles(api) + except VolvoApiException: + _LOGGER.exception("Unable to retrieve vehicles") + errors["base"] = "cannot_load_vehicles" + + if not errors: + self._config_data |= user_input + return await self.async_step_vin() + + if user_input is None: + if self.source == SOURCE_REAUTH: + user_input = self._config_data + api = _create_volvo_cars_api( + self.hass, + self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], + self._config_data[CONF_API_KEY], + ) + + # Test if the configured API key is still valid. If not, show this + # form. If it is, skip this step and go directly to the next step. + try: + await self._async_load_vehicles(api) + return await self.async_step_vin() + except VolvoApiException: + pass + + elif self.source == SOURCE_RECONFIGURE: + user_input = self._config_data = dict( + self._get_reconfigure_entry().data + ) + else: + user_input = {} + + schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, autocomplete="password" + ) + ), + } + ), + { + CONF_API_KEY: user_input.get(CONF_API_KEY, ""), + }, + ) + + return self.async_show_form( + step_id="api_key", + data_schema=schema, + errors=errors, + description_placeholders={ + "volvo_dev_portal": "https://developer.volvocars.com/account/#your-api-applications" + }, + ) + + async def async_step_vin( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the VIN step.""" + errors: dict[str, str] = {} + + if len(self._vehicles) == 1: + # If there is only one VIN, take that as value and + # immediately create the entry. No need to show + # the VIN step. + self._config_data[CONF_VIN] = self._vehicles[0].vin + return await self._async_create_or_update() + + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + # Don't let users change the VIN. The entry should be + # recreated if they want to change the VIN. + return await self._async_create_or_update() + + if user_input is not None: + self._config_data |= user_input + return await self._async_create_or_update() + + if len(self._vehicles) == 0: + errors[CONF_VIN] = "no_vehicles" + + schema = vol.Schema( + { + vol.Required(CONF_VIN): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=v.vin, + label=f"{v.description.model} ({v.vin})", + ) + for v in self._vehicles + ], + multiple=False, + ) + ), + }, + ) + + return self.async_show_form(step_id="vin", data_schema=schema, errors=errors) + + async def _async_create_or_update(self) -> ConfigFlowResult: + vin = self._config_data[CONF_VIN] + await self.async_set_unique_id(vin) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=self._config_data, + ) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self._config_data, + reload_even_if_entry_is_unchanged=False, + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {vin}", + data=self._config_data, + ) + + async def _async_load_vehicles(self, api: VolvoCarsApi) -> None: + self._vehicles = [] + vins = await api.async_get_vehicles() + + for vin in vins: + vehicle = await api.async_get_vehicle_details(vin) + + if vehicle: + self._vehicles.append(vehicle) diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py new file mode 100644 index 00000000000..675fc69945e --- /dev/null +++ b/homeassistant/components/volvo/const.py @@ -0,0 +1,14 @@ +"""Constants for the Volvo integration.""" + +from homeassistant.const import Platform + +DOMAIN = "volvo" +PLATFORMS: list[Platform] = [Platform.SENSOR] + +ATTR_API_TIMESTAMP = "api_timestamp" + +CONF_VIN = "vin" + +DATA_BATTERY_CAPACITY = "battery_capacity_kwh" + +MANUFACTURER = "Volvo" diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py new file mode 100644 index 00000000000..d6c8f349a52 --- /dev/null +++ b/homeassistant/components/volvo/coordinator.py @@ -0,0 +1,299 @@ +"""Volvo coordinators.""" + +from __future__ import annotations + +from abc import abstractmethod +import asyncio +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any, cast + +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsValueStatusField, + VolvoCarsVehicle, +) + +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 DATA_BATTERY_CAPACITY, DOMAIN + +VERY_SLOW_INTERVAL = 60 +SLOW_INTERVAL = 15 +MEDIUM_INTERVAL = 2 + +_LOGGER = logging.getLogger(__name__) + + +type VolvoConfigEntry = ConfigEntry[tuple[VolvoBaseCoordinator, ...]] +type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None] + + +def _is_invalid_api_field(field: VolvoCarsApiBaseModel | None) -> bool: + if not field: + return True + + if isinstance(field, VolvoCarsValueStatusField) and field.status == "ERROR": + return True + + return False + + +class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Volvo base coordinator.""" + + config_entry: VolvoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + update_interval: timedelta, + name: str, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=name, + update_interval=update_interval, + ) + + self.api = api + self.vehicle = vehicle + + self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = [] + + async def _async_setup(self) -> None: + self._api_calls = await self._async_determine_api_calls() + + if not self._api_calls: + self.update_interval = None + + async def _async_update_data(self) -> CoordinatorData: + """Fetch data from API.""" + + data: CoordinatorData = {} + + if not self._api_calls: + return data + + valid = False + exception: Exception | None = None + + results = await asyncio.gather( + *(call() for call in self._api_calls), return_exceptions=True + ) + + for result in results: + if isinstance(result, VolvoAuthException): + # If one result is a VolvoAuthException, then probably all requests + # will fail. In this case we can cancel everything to + # reauthenticate. + # + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + _LOGGER.debug( + "%s - Authentication failed. %s", + self.config_entry.entry_id, + result.message, + ) + raise ConfigEntryAuthFailed( + f"Authentication failed. {result.message}" + ) from result + + if isinstance(result, VolvoApiException): + # Maybe it's just one call that fails. Log the error and + # continue processing the other calls. + _LOGGER.debug( + "%s - Error during data update: %s", + self.config_entry.entry_id, + result.message, + ) + exception = exception or result + continue + + if isinstance(result, Exception): + # Something bad happened, raise immediately. + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from result + + api_data = cast(CoordinatorData, result) + data |= { + key: field + for key, field in api_data.items() + if not _is_invalid_api_field(field) + } + + valid = True + + # Raise an error if not a single API call succeeded + if not valid: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from exception + + return data + + def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None: + """Get the API field based on the entity description.""" + + return self.data.get(api_field) if api_field else None + + @abstractmethod + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + raise NotImplementedError + + +class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with very slow update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=VERY_SLOW_INTERVAL), + "Volvo very slow interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + return [ + self.api.async_get_diagnostics, + self.api.async_get_odometer, + self.api.async_get_statistics, + ] + + async def _async_update_data(self) -> CoordinatorData: + data = await super()._async_update_data() + + # Add static values + if self.vehicle.has_battery_engine(): + data[DATA_BATTERY_CAPACITY] = VolvoCarsValue.from_dict( + { + "value": self.vehicle.battery_capacity_kwh, + } + ) + + return data + + +class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with slow update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=SLOW_INTERVAL), + "Volvo slow interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + if self.vehicle.has_combustion_engine(): + return [ + self.api.async_get_command_accessibility, + self.api.async_get_fuel_status, + ] + + return [self.api.async_get_command_accessibility] + + +class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with medium update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=MEDIUM_INTERVAL), + "Volvo medium interval coordinator", + ) + + self._supported_capabilities: list[str] = [] + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + if self.vehicle.has_battery_engine(): + capabilities = await self.api.async_get_energy_capabilities() + + if capabilities.get("isSupported", False): + self._supported_capabilities = [ + key + for key, value in capabilities.items() + if isinstance(value, dict) and value.get("isSupported", False) + ] + + return [self._async_get_energy_state] + + return [] + + async def _async_get_energy_state( + self, + ) -> dict[str, VolvoCarsValueStatusField | None]: + def _mark_ok( + field: VolvoCarsValueStatusField | None, + ) -> VolvoCarsValueStatusField | None: + if field: + field.status = "OK" + + return field + + energy_state = await self.api.async_get_energy_state() + + return { + key: _mark_ok(value) + for key, value in energy_state.items() + if key in self._supported_capabilities + } diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py new file mode 100644 index 00000000000..f23bd714870 --- /dev/null +++ b/homeassistant/components/volvo/entity.py @@ -0,0 +1,90 @@ +"""Volvo entity classes.""" + +from abc import abstractmethod +from dataclasses import dataclass + +from volvocarsapi.models import VolvoCarsApiBaseModel + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_VIN, DOMAIN, MANUFACTURER +from .coordinator import VolvoBaseCoordinator + + +def get_unique_id(vin: str, key: str) -> str: + """Get the unique ID.""" + return f"{vin}_{key}".lower() + + +def value_to_translation_key(value: str) -> str: + """Make sure the translation key is valid.""" + return value.lower() + + +@dataclass(frozen=True, kw_only=True) +class VolvoEntityDescription(EntityDescription): + """Describes a Volvo entity.""" + + api_field: str + + +class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator]): + """Volvo base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: VolvoBaseCoordinator, + description: VolvoEntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + + self.entity_description: VolvoEntityDescription = description + + if description.device_class != SensorDeviceClass.BATTERY: + self._attr_translation_key = description.key + + self._attr_unique_id = get_unique_id( + coordinator.config_entry.data[CONF_VIN], description.key + ) + + vehicle = coordinator.vehicle + model = ( + f"{vehicle.description.model} ({vehicle.model_year})" + if vehicle.fuel_type == "NONE" + else f"{vehicle.description.model} {vehicle.fuel_type} ({vehicle.model_year})" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer=MANUFACTURER, + model=model, + name=f"{MANUFACTURER} {vehicle.description.model}", + serial_number=vehicle.vin, + ) + + self._update_state(coordinator.get_api_field(description.api_field)) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + api_field = self.coordinator.get_api_field(self.entity_description.api_field) + self._update_state(api_field) + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + api_field = self.coordinator.get_api_field(self.entity_description.api_field) + return super().available and api_field is not None + + @abstractmethod + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + """Update the state of the entity.""" + raise NotImplementedError diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json new file mode 100644 index 00000000000..61f67bcfe04 --- /dev/null +++ b/homeassistant/components/volvo/icons.json @@ -0,0 +1,85 @@ +{ + "entity": { + "sensor": { + "availability": { + "default": "mdi:car-connected" + }, + "average_energy_consumption": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_automatic": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_charge": { + "default": "mdi:car-electric" + }, + "average_fuel_consumption": { + "default": "mdi:gas-station" + }, + "average_fuel_consumption_automatic": { + "default": "mdi:gas-station" + }, + "charger_connection_status": { + "default": "mdi:power-plug-off", + "state": { + "connected": "mdi:power-plug", + "fault": "mdi:flash-alert" + } + }, + "charging_power": { + "default": "mdi:gauge-empty", + "range": { + "1": "mdi:gauge-low", + "4200": "mdi:gauge", + "7400": "mdi:gauge-full" + } + }, + "charging_power_status": { + "default": "mdi:power-plug-outline" + }, + "charging_status": { + "default": "mdi:ev-station" + }, + "charging_type": { + "default": "mdi:power-plug-off-outline", + "state": { + "ac": "mdi:current-ac", + "dc": "mdi:current-dc" + } + }, + "distance_to_empty_battery": { + "default": "mdi:battery-outline" + }, + "distance_to_empty_tank": { + "default": "mdi:gauge-empty" + }, + "distance_to_service": { + "default": "mdi:wrench-check" + }, + "engine_time_to_service": { + "default": "mdi:wrench-cog" + }, + "estimated_charging_time": { + "default": "mdi:battery-clock" + }, + "fuel_amount": { + "default": "mdi:fuel" + }, + "odometer": { + "default": "mdi:counter" + }, + "target_battery_charge_level": { + "default": "mdi:battery-medium" + }, + "time_to_service": { + "default": "mdi:wrench-clock" + }, + "trip_meter_automatic": { + "default": "mdi:map-marker-distance" + }, + "trip_meter_manual": { + "default": "mdi:map-marker-distance" + } + } + } +} diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json new file mode 100644 index 00000000000..1530634a10a --- /dev/null +++ b/homeassistant/components/volvo/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "volvo", + "name": "Volvo", + "codeowners": ["@thomasddn"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/volvo", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["volvocarsapi"], + "quality_scale": "silver", + "requirements": ["volvocarsapi==0.4.1"] +} diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml new file mode 100644 index 00000000000..ac91fd001d1 --- /dev/null +++ b/homeassistant/components/volvo/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: 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: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No discovery possible. + discovery: + status: exempt + comment: | + No discovery possible. + 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: exempt + comment: | + Devices are handpicked because there is a rate limit on the API, which we + would hit if all devices (vehicles) are added under the same API key. + 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: + status: exempt + comment: | + Devices are handpicked. See dynamic-devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py new file mode 100644 index 00000000000..b9a620d898d --- /dev/null +++ b/homeassistant/components/volvo/sensor.py @@ -0,0 +1,408 @@ +"""Volvo sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, cast + +from volvocarsapi.models import ( + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsValueField, + VolvoCarsValueStatusField, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfEnergyDistance, + UnitOfLength, + UnitOfPower, + UnitOfSpeed, + UnitOfTime, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DATA_BATTERY_CAPACITY +from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry +from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): + """Describes a Volvo sensor entity.""" + + value_fn: Callable[[VolvoCarsValue], Any] | None = None + + +def _availability_status(field: VolvoCarsValue) -> str: + reason = field.get("unavailable_reason") + return reason if reason else str(field.value) + + +def _calculate_time_to_service(field: VolvoCarsValue) -> int: + value = int(field.value) + + # Always express value in days + if isinstance(field, VolvoCarsValueField) and field.unit == "months": + return value * 30 + + return value + + +def _charging_power_value(field: VolvoCarsValue) -> int: + return ( + field.value + if isinstance(field, VolvoCarsValueStatusField) and isinstance(field.value, int) + else 0 + ) + + +def _charging_power_status_value(field: VolvoCarsValue) -> str | None: + status = cast(str, field.value) + + if status.lower() in _CHARGING_POWER_STATUS_OPTIONS: + return status + + _LOGGER.warning( + "Unknown value '%s' for charging_power_status. Please report it at https://github.com/home-assistant/core/issues/new?template=bug_report.yml", + status, + ) + return None + + +_CHARGING_POWER_STATUS_OPTIONS = [ + "fault", + "power_available_but_not_activated", + "providing_power", + "no_power_available", +] + +_DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( + # command-accessibility endpoint + VolvoSensorDescription( + key="availability", + api_field="availabilityStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "available", + "car_in_use", + "no_internet", + "ota_installation_in_progress", + "power_saving_mode", + ], + value_fn=_availability_status, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption", + api_field="averageEnergyConsumption", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_automatic", + api_field="averageEnergyConsumptionAutomatic", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_charge", + api_field="averageEnergyConsumptionSinceCharge", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption", + api_field="averageFuelConsumption", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption_automatic", + api_field="averageFuelConsumptionAutomatic", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed", + api_field="averageSpeed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed_automatic", + api_field="averageSpeedAutomatic", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + # vehicle endpoint + VolvoSensorDescription( + key="battery_capacity", + api_field=DATA_BATTERY_CAPACITY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # fuel & energy state endpoint + VolvoSensorDescription( + key="battery_charge_level", + api_field="batteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + # energy state endpoint + VolvoSensorDescription( + key="charger_connection_status", + api_field="chargerConnectionStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "connected", + "disconnected", + "fault", + ], + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_current_limit", + api_field="chargingCurrentLimit", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_power", + api_field="chargingPower", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=_charging_power_value, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_power_status", + api_field="chargerPowerStatus", + device_class=SensorDeviceClass.ENUM, + options=_CHARGING_POWER_STATUS_OPTIONS, + value_fn=_charging_power_status_value, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_status", + api_field="chargingStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "charging", + "discharging", + "done", + "error", + "idle", + "scheduled", + ], + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_type", + api_field="chargingType", + device_class=SensorDeviceClass.ENUM, + options=[ + "ac", + "dc", + "none", + ], + ), + # statistics endpoint + # We're not using `electricRange` from the energy state endpoint because + # the official app seems to use `distanceToEmptyBattery`. + # In issue #150213, a user described to behavior as follows: + # - For a `distanceToEmptyBattery` of 250km, the `electricRange` was 150mi + # - For a `distanceToEmptyBattery` of 260km, the `electricRange` was 160mi + VolvoSensorDescription( + key="distance_to_empty_battery", + api_field="distanceToEmptyBattery", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + # statistics endpoint + VolvoSensorDescription( + key="distance_to_empty_tank", + api_field="distanceToEmptyTank", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="distance_to_service", + api_field="distanceToService", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="engine_time_to_service", + api_field="engineHoursToService", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + # energy state endpoint + VolvoSensorDescription( + key="estimated_charging_time", + api_field="estimatedChargingTimeToTargetBatteryChargeLevel", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + # fuel endpoint + VolvoSensorDescription( + key="fuel_amount", + api_field="fuelAmount", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + # odometer endpoint + VolvoSensorDescription( + key="odometer", + api_field="odometer", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + ), + # energy state endpoint + VolvoSensorDescription( + key="target_battery_charge_level", + api_field="targetBatteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="time_to_service", + api_field="timeToService", + native_unit_of_measurement=UnitOfTime.DAYS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=_calculate_time_to_service, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_automatic", + api_field="tripMeterAutomatic", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_manual", + api_field="tripMeterManual", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors.""" + + entities: list[VolvoSensor] = [] + added_keys: set[str] = set() + + def _add_entity( + coordinator: VolvoBaseCoordinator, description: VolvoSensorDescription + ) -> None: + entities.append(VolvoSensor(coordinator, description)) + added_keys.add(description.key) + + coordinators = entry.runtime_data + + for coordinator in coordinators: + for description in _DESCRIPTIONS: + if description.key in added_keys: + continue + + if description.api_field in coordinator.data: + _add_entity(coordinator, description) + + async_add_entities(entities) + + +class VolvoSensor(VolvoEntity, SensorEntity): + """Volvo sensor.""" + + entity_description: VolvoSensorDescription + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + """Update the state of the entity.""" + if api_field is None: + self._attr_native_value = None + return + + assert isinstance(api_field, VolvoCarsValue) + + native_value = ( + api_field.value + if self.entity_description.value_fn is None + else self.entity_description.value_fn(api_field) + ) + + if self.device_class == SensorDeviceClass.ENUM and native_value: + # Entities having an "unknown" value should report None as the state + native_value = str(native_value) + native_value = ( + value_to_translation_key(native_value) + if native_value.upper() != "UNSPECIFIED" + else None + ) + + self._attr_native_value = native_value diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json new file mode 100644 index 00000000000..c429c106574 --- /dev/null +++ b/homeassistant/components/volvo/strings.json @@ -0,0 +1,180 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Volvo integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "api_key": { + "description": "Get your API key from the [Volvo developer portal]({volvo_dev_portal}).", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The Volvo developers API key" + } + }, + "vin": { + "description": "Select a vehicle", + "data": { + "vin": "VIN" + }, + "data_description": { + "vin": "The Vehicle Identification Number of the vehicle you want to add" + } + } + }, + "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%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "error": { + "cannot_load_vehicles": "Unable to retrieve vehicles.", + "no_vehicles": "No vehicles found on this account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "sensor": { + "availability": { + "name": "Car connection", + "state": { + "available": "Available", + "car_in_use": "Car is in use", + "no_internet": "No internet", + "ota_installation_in_progress": "Installing OTA update", + "power_saving_mode": "Power saving mode", + "unavailable": "Unavailable" + } + }, + "average_energy_consumption": { + "name": "Trip manual average energy consumption" + }, + "average_energy_consumption_automatic": { + "name": "Trip automatic average energy consumption" + }, + "average_energy_consumption_charge": { + "name": "Average energy consumption since charge" + }, + "average_fuel_consumption": { + "name": "Trip manual average fuel consumption" + }, + "average_fuel_consumption_automatic": { + "name": "Trip automatic average fuel consumption" + }, + "average_speed": { + "name": "Trip manual average speed" + }, + "average_speed_automatic": { + "name": "Trip automatic average speed" + }, + "battery_capacity": { + "name": "Battery capacity" + }, + "battery_charge_level": { + "name": "Battery charge level" + }, + "charger_connection_status": { + "name": "Charging connection status", + "state": { + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", + "fault": "[%key:common::state::fault%]" + } + }, + "charging_current_limit": { + "name": "Charging limit" + }, + "charging_power": { + "name": "Charging power" + }, + "charging_power_status": { + "name": "Charging power status", + "state": { + "fault": "[%key:common::state::fault%]", + "power_available_but_not_activated": "Power available", + "providing_power": "Providing power", + "no_power_available": "No power" + } + }, + "charging_status": { + "name": "Charging status", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "done": "Done", + "error": "[%key:common::state::error%]", + "idle": "[%key:common::state::idle%]", + "scheduled": "Scheduled" + } + }, + "charging_type": { + "name": "Charging type", + "state": { + "ac": "AC", + "dc": "DC", + "none": "None" + } + }, + "distance_to_empty_battery": { + "name": "Distance to empty battery" + }, + "distance_to_empty_tank": { + "name": "Distance to empty tank" + }, + "distance_to_service": { + "name": "Distance to service" + }, + "engine_time_to_service": { + "name": "Time to engine service" + }, + "estimated_charging_time": { + "name": "Estimated charging time" + }, + "fuel_amount": { + "name": "Fuel amount" + }, + "odometer": { + "name": "Odometer" + }, + "target_battery_charge_level": { + "name": "Target battery charge level" + }, + "time_to_service": { + "name": "Time to service" + }, + "trip_meter_automatic": { + "name": "Trip automatic distance" + }, + "trip_meter_manual": { + "name": "Trip manual distance" + } + } + }, + "exceptions": { + "no_vehicle": { + "message": "Unable to retrieve vehicle details." + }, + "update_failed": { + "message": "Unable to update data." + }, + "unauthorized": { + "message": "Authentication failed. {message}" + } + } +} diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index 554a82e9c2c..f9385262f05 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", - "requirements": ["vulcan-api==2.3.2"] + "requirements": ["vulcan-api==2.4.2"] } diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 9336ab0e36b..43b5d3ef91f 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -4,13 +4,17 @@ from __future__ import annotations from wallbox import Wallbox -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, UPDATE_INTERVAL -from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input +from .const import UPDATE_INTERVAL +from .coordinator import ( + InvalidAuth, + WallboxConfigEntry, + WallboxCoordinator, + async_validate_input, +) PLATFORMS = [ Platform.LOCK, @@ -21,7 +25,7 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> bool: """Set up Wallbox from a config entry.""" wallbox = Wallbox( entry.data[CONF_USERNAME], @@ -36,17 +40,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox) await wallbox_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = wallbox_coordinator + entry.runtime_data = wallbox_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: WallboxConfigEntry) -> 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/wallbox/const.py b/homeassistant/components/wallbox/const.py index 34d17e52275..1059a41db53 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -22,6 +22,8 @@ CHARGER_CURRENT_MODE_KEY = "current_mode" CHARGER_CURRENT_VERSION_KEY = "currentVersion" CHARGER_CURRENCY_KEY = "currency" CHARGER_DATA_KEY = "config_data" +CHARGER_DATA_POST_L1_KEY = "data" +CHARGER_DATA_POST_L2_KEY = "chargerData" CHARGER_DEPOT_PRICE_KEY = "depot_price" CHARGER_ENERGY_PRICE_KEY = "energy_price" CHARGER_FEATURES_KEY = "features" @@ -32,7 +34,9 @@ CHARGER_POWER_BOOST_KEY = "POWER_BOOST" CHARGER_SOFTWARE_KEY = "software" CHARGER_MAX_AVAILABLE_POWER_KEY = "max_available_power" CHARGER_MAX_CHARGING_CURRENT_KEY = "max_charging_current" +CHARGER_MAX_CHARGING_CURRENT_POST_KEY = "maxChargingCurrent" CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" +CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent" CHARGER_PAUSE_RESUME_KEY = "paused" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 598bfa7429a..4e743b2106b 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -14,11 +14,14 @@ from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, + CHARGER_DATA_POST_L1_KEY, + CHARGER_DATA_POST_L2_KEY, CHARGER_ECO_SMART_KEY, CHARGER_ECO_SMART_MODE_KEY, CHARGER_ECO_SMART_STATUS_KEY, @@ -26,6 +29,7 @@ from .const import ( CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_CHARGING_CURRENT_POST_KEY, CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PLAN_KEY, CHARGER_POWER_BOOST_KEY, @@ -74,6 +78,8 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 210: ChargerStatus.LOCKED_CAR_CONNECTED, } +type WallboxConfigEntry = ConfigEntry[WallboxCoordinator] + def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any], @@ -89,7 +95,9 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( return func(self, *args, **kwargs) except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed from wallbox_connection_error + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error @@ -103,7 +111,9 @@ def _validate(wallbox: Wallbox) -> None: wallbox.authenticate() except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InvalidAuth( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error raise ConnectionError from wallbox_connection_error @@ -115,10 +125,10 @@ async def async_validate_input(hass: HomeAssistant, wallbox: Wallbox) -> None: class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" - config_entry: ConfigEntry + config_entry: WallboxConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, wallbox: Wallbox + self, hass: HomeAssistant, config_entry: WallboxConfigEntry, wallbox: Wallbox ) -> None: """Initialize.""" self._station = config_entry.data[CONF_STATION] @@ -188,14 +198,13 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE elif eco_smart_mode == 1: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR - return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="too_many_requests" ) from wallbox_connection_error - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error @@ -204,13 +213,26 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return await self.hass.async_add_executor_job(self._get_data) @_require_authentication - def _set_charging_current(self, charging_current: float) -> None: + def _set_charging_current( + self, charging_current: float + ) -> dict[str, dict[str, dict[str, Any]]]: """Set maximum charging current for Wallbox.""" try: - self._wallbox.setMaxChargingCurrent(self._station, charging_current) + result = self._wallbox.setMaxChargingCurrent( + self._station, charging_current + ) + data = self.data + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_MAX_CHARGING_CURRENT_POST_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, + ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="too_many_requests" @@ -221,19 +243,26 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" - await self.hass.async_add_executor_job( + data = await self.hass.async_add_executor_job( self._set_charging_current, charging_current ) - await self.async_request_refresh() + self.async_set_updated_data(data) @_require_authentication - def _set_icp_current(self, icp_current: float) -> None: + def _set_icp_current(self, icp_current: float) -> dict[str, Any]: """Set maximum icp current for Wallbox.""" try: - self._wallbox.setIcpMaxCurrent(self._station, icp_current) + result = self._wallbox.setIcpMaxCurrent(self._station, icp_current) + data = self.data + data[CHARGER_MAX_ICP_CURRENT_KEY] = result[CHARGER_MAX_ICP_CURRENT_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, + ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="too_many_requests" @@ -244,14 +273,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" - await self.hass.async_add_executor_job(self._set_icp_current, icp_current) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_icp_current, icp_current + ) + self.async_set_updated_data(data) @_require_authentication - def _set_energy_cost(self, energy_cost: float) -> None: + def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]: """Set energy cost for Wallbox.""" try: - self._wallbox.setEnergyCost(self._station, energy_cost) + result = self._wallbox.setEnergyCost(self._station, energy_cost) + data = self.data + data[CHARGER_ENERGY_PRICE_KEY] = result[CHARGER_ENERGY_PRICE_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -263,20 +297,31 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_energy_cost, energy_cost + ) + self.async_set_updated_data(data) @_require_authentication - def _set_lock_unlock(self, lock: bool) -> None: + def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]: """Set wallbox to locked or unlocked.""" try: if lock: - self._wallbox.lockCharger(self._station) + result = self._wallbox.lockCharger(self._station) else: - self._wallbox.unlockCharger(self._station) + result = self._wallbox.unlockCharger(self._station) + data = self.data + data[CHARGER_LOCKED_UNLOCKED_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_LOCKED_UNLOCKED_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, + ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="too_many_requests" @@ -287,8 +332,8 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" - await self.hass.async_add_executor_job(self._set_lock_unlock, lock) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock) + self.async_set_updated_data(data) @_require_authentication def _pause_charger(self, pause: bool) -> None: @@ -340,3 +385,34 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class InsufficientRights(HomeAssistantError): + """Error to indicate there are insufficient right for the user.""" + + def __init__( + self, + *args: object, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + hass: HomeAssistant, + ) -> None: + """Initialize exception.""" + super().__init__( + self, *args, translation_domain, translation_key, translation_placeholders + ) + self.hass = hass + self._create_insufficient_rights_issue() + + def _create_insufficient_rights_issue(self) -> None: + """Creates an issue for insufficient rights.""" + ir.create_issue( + self.hass, + DOMAIN, + "insufficient_rights", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + learn_more_url="https://www.home-assistant.io/integrations/wallbox/#troubleshooting", + translation_key="insufficient_rights", + ) diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7acc56f67f2..f48ac000110 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -5,18 +5,15 @@ from __future__ import annotations from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CHARGER_DATA_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_SERIAL_NUMBER_KEY, - DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { @@ -29,21 +26,11 @@ LOCK_TYPES: dict[str, LockEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox lock entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to lock, if so, add lock component - try: - await coordinator.async_set_lock_unlock( - coordinator.data[CHARGER_LOCKED_UNLOCKED_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( WallboxLock(coordinator, description) for ent in coordinator.data @@ -51,6 +38,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxLock(WallboxEntity, LockEntity): """Representation of a wallbox lock.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 80773478582..6bc37778a61 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -10,9 +10,7 @@ from dataclasses import dataclass from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -24,9 +22,8 @@ from .const import ( CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PART_NUMBER_KEY, CHARGER_SERIAL_NUMBER_KEY, - DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -81,21 +78,11 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox number entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user has sufficient rights to change values, if so, add number component: - try: - await coordinator.async_set_charging_current( - coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( WallboxNumber(coordinator, entry, description) for ent in coordinator.data @@ -103,6 +90,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxNumber(WallboxEntity, NumberEntity): """Representation of the Wallbox portal.""" @@ -111,7 +102,7 @@ class WallboxNumber(WallboxEntity, NumberEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, + entry: WallboxConfigEntry, description: WallboxNumberEntityDescription, ) -> None: """Initialize a Wallbox number entity.""" diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 0048aa35c7c..8d4cf252344 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from requests import HTTPError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,7 +22,7 @@ from .const import ( DOMAIN, EcoSmartMode, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -58,11 +57,11 @@ SELECT_TYPES: dict[str, WallboxSelectEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox select entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data if coordinator.data[CHARGER_ECO_SMART_KEY] != EcoSmartMode.DISABLED: async_add_entities( WallboxSelect(coordinator, description) @@ -74,6 +73,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxSelect(WallboxEntity, SelectEntity): """Representation of the Wallbox portal.""" diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index e19fc2b936a..b59e1e5319d 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfElectricCurrent, @@ -43,9 +42,8 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, CHARGER_STATE_OF_CHARGE_KEY, CHARGER_STATUS_DESCRIPTION_KEY, - DOMAIN, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -170,11 +168,11 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( WallboxSensor(coordinator, description) @@ -183,6 +181,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxSensor(WallboxEntity, SensorEntity): """Representation of the Wallbox portal.""" diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index ee98a4855e3..c59b5389658 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -3,9 +3,14 @@ "step": { "user": { "data": { - "station": "Station Serial Number", + "station": "Station serial number", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "station": "Serial number of the charger. Can be found in the Wallbox app or in the Wallbox portal.", + "username": "Username for your Wallbox account.", + "password": "Password for your Wallbox account." } }, "reauth_confirm": { @@ -19,7 +24,7 @@ "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%]", - "reauth_invalid": "Re-authentication failed; Serial Number does not match original" + "reauth_invalid": "Re-authentication failed; serial number does not match original" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -109,12 +114,24 @@ } } }, + "issues": { + "insufficient_rights": { + "title": "The Wallbox account has insufficient rights.", + "description": "The Wallbox account has insufficient rights to lock/unlock and change the charging power. Please assign the user admin rights in the Wallbox portal." + } + }, "exceptions": { "api_failed": { "message": "Error communicating with Wallbox API" }, "too_many_requests": { "message": "Error communicating with Wallbox API, too many requests" + }, + "invalid_auth": { + "message": "Invalid authentication" + }, + "insufficient_rights": { + "message": "Insufficient rights for Wallbox user" } } } diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 30275951ab2..74f1783f539 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,10 +13,9 @@ from .const import ( CHARGER_PAUSE_RESUME_KEY, CHARGER_SERIAL_NUMBER_KEY, CHARGER_STATUS_DESCRIPTION_KEY, - DOMAIN, ChargerStatus, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity SWITCH_TYPES: dict[str, SwitchEntityDescription] = { @@ -30,16 +28,20 @@ SWITCH_TYPES: dict[str, SwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( [WallboxSwitch(coordinator, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxSwitch(WallboxEntity, SwitchEntity): """Representation of the Wallbox portal.""" diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 9821b5435d9..7b1243ed905 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -4,18 +4,16 @@ from __future__ import annotations from aiowaqi import WAQIClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool: """Set up World Air Quality Index (WAQI) from a config entry.""" client = WAQIClient(session=async_get_clientsession(hass)) @@ -23,16 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client) await waqi_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator + entry.runtime_data = waqi_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: WAQIConfigEntry) -> 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/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index 51ba801c92e..8ed2dcd8425 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -66,24 +66,22 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(user_input[CONF_API_KEY]) - try: - await waqi_client.get_by_ip() - except WAQIAuthenticationError: - errors["base"] = "invalid_auth" - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self.data = user_input - if user_input[CONF_METHOD] == CONF_MAP: - return await self.async_step_map() - return await self.async_step_station_number() + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(user_input[CONF_API_KEY]) + try: + await client.get_by_ip() + except WAQIAuthenticationError: + errors["base"] = "invalid_auth" + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() return self.async_show_form( step_id="user", @@ -107,22 +105,20 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via map.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - try: - measuring_station = await waqi_client.get_by_coordinates( - user_input[CONF_LOCATION][CONF_LATITUDE], - user_input[CONF_LOCATION][CONF_LONGITUDE], - ) - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return await self._async_create_entry(measuring_station) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await client.get_by_coordinates( + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], + ) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_MAP, data_schema=self.add_suggested_values_to_schema( @@ -149,21 +145,19 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via station number.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - station_number = user_input[CONF_STATION_NUMBER] - measuring_station, errors = await get_by_station_number( - waqi_client, abs(station_number) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + station_number = user_input[CONF_STATION_NUMBER] + measuring_station, errors = await get_by_station_number( + client, abs(station_number) + ) + if not measuring_station: + measuring_station, _ = await get_by_station_number( + client, + abs(station_number) - station_number - station_number, ) - if not measuring_station: - measuring_station, _ = await get_by_station_number( - waqi_client, - abs(station_number) - station_number - station_number, - ) - if measuring_station: - return await self._async_create_entry(measuring_station) + if measuring_station: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_STATION_NUMBER, data_schema=vol.Schema( diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index 86f553a86cd..f40df4a1b89 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -12,14 +12,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER +type WAQIConfigEntry = ConfigEntry[WAQIDataUpdateCoordinator] + class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): """The WAQI Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: WAQIConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: WAQIClient + self, hass: HomeAssistant, config_entry: WAQIConfigEntry, client: WAQIClient ) -> None: """Initialize the WAQI data coordinator.""" super().__init__( diff --git a/homeassistant/components/waqi/icons.json b/homeassistant/components/waqi/icons.json new file mode 100644 index 00000000000..545e49fd54e --- /dev/null +++ b/homeassistant/components/waqi/icons.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "sulphur_dioxide": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "neph": { + "default": "mdi:eye" + }, + "dominant_pollutant": { + "default": "mdi:molecule", + "state": { + "co": "mdi:molecule-co", + "neph": "mdi:eye", + "no2": "mdi:molecule", + "o3": "mdi:molecule", + "so2": "mdi:molecule", + "pm10": "mdi:molecule", + "pm25": "mdi:molecule" + } + } + } + } +} diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 59daf60392e..c887d893c08 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -import logging from aiowaqi import WAQIAirQuality from aiowaqi.models import Pollutant @@ -15,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,18 +22,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -ATTR_DOMINENTPOL = "dominentpol" -ATTR_HUMIDITY = "humidity" -ATTR_NITROGEN_DIOXIDE = "nitrogen_dioxide" -ATTR_OZONE = "ozone" -ATTR_PM10 = "pm_10" -ATTR_PM2_5 = "pm_2_5" -ATTR_PRESSURE = "pressure" -ATTR_SULFUR_DIOXIDE = "sulfur_dioxide" +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -139,11 +126,11 @@ SENSORS: list[WAQISensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WAQIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WAQI sensor.""" - coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( WaqiSensor(coordinator, sensor) for sensor in SENSORS diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 3a91690ef07..2e719a41a21 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -1,15 +1,13 @@ """The waze_travel_time component.""" import asyncio -from collections.abc import Collection import logging -from typing import Literal -from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError +from pywaze.route_calculator import WazeRouteCalculator import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_REGION, Platform, UnitOfLength +from homeassistant.const import CONF_REGION, Platform from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -27,7 +25,6 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) -from homeassistant.util.unit_conversion import DistanceConverter from .const import ( CONF_AVOID_FERRIES, @@ -43,13 +40,13 @@ from .const import ( DEFAULT_FILTER, DEFAULT_VEHICLE_TYPE, DOMAIN, - IMPERIAL_UNITS, METRIC_UNITS, REGIONS, SEMAPHORE, UNITS, VEHICLE_TYPES, ) +from .coordinator import WazeTravelTimeCoordinator, async_get_travel_times PLATFORMS = [Platform.SENSOR] @@ -109,6 +106,16 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}): hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1) + httpx_client = get_async_client(hass) + client = WazeRouteCalculator( + region=config_entry.data[CONF_REGION].upper(), client=httpx_client + ) + + coordinator = WazeTravelTimeCoordinator(hass, config_entry, client) + config_entry.runtime_data = coordinator + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse: @@ -140,7 +147,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b incl_filters=service.data.get(CONF_INCL_FILTER, DEFAULT_FILTER), excl_filters=service.data.get(CONF_EXCL_FILTER, DEFAULT_FILTER), ) - return {"routes": [vars(route) for route in response]} if response else None + return {"routes": [vars(route) for route in response]} hass.services.async_register( DOMAIN, @@ -152,106 +159,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_get_travel_times( - client: WazeRouteCalculator, - origin: str, - destination: str, - vehicle_type: str, - avoid_toll_roads: bool, - avoid_subscription_roads: bool, - avoid_ferries: bool, - realtime: bool, - units: Literal["metric", "imperial"] = "metric", - incl_filters: Collection[str] | None = None, - excl_filters: Collection[str] | None = None, -) -> list[CalcRoutesResponse] | None: - """Get all available routes.""" - - incl_filters = incl_filters or () - excl_filters = excl_filters or () - - _LOGGER.debug( - "Getting update for origin: %s destination: %s", - origin, - destination, - ) - routes = [] - vehicle_type = "" if vehicle_type.upper() == "CAR" else vehicle_type.upper() - try: - routes = await client.calc_routes( - origin, - destination, - vehicle_type=vehicle_type, - avoid_toll_roads=avoid_toll_roads, - avoid_subscription_roads=avoid_subscription_roads, - avoid_ferries=avoid_ferries, - real_time=realtime, - alternatives=3, - ) - _LOGGER.debug("Got routes: %s", routes) - - incl_routes: list[CalcRoutesResponse] = [] - - def should_include_route(route: CalcRoutesResponse) -> bool: - if len(incl_filters) < 1: - return True - should_include = any( - street_name in incl_filters or "" in incl_filters - for street_name in route.street_names - ) - if not should_include: - _LOGGER.debug( - "Excluding route [%s], because no inclusive filter matched any streetname", - route.name, - ) - return False - return True - - incl_routes = [route for route in routes if should_include_route(route)] - - filtered_routes: list[CalcRoutesResponse] = [] - - def should_exclude_route(route: CalcRoutesResponse) -> bool: - for street_name in route.street_names: - for excl_filter in excl_filters: - if excl_filter == street_name: - _LOGGER.debug( - "Excluding route, because exclusive filter [%s] matched streetname: %s", - excl_filter, - route.name, - ) - return True - return False - - filtered_routes = [ - route for route in incl_routes if not should_exclude_route(route) - ] - - if units == IMPERIAL_UNITS: - filtered_routes = [ - CalcRoutesResponse( - name=route.name, - distance=DistanceConverter.convert( - route.distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES - ), - duration=route.duration, - street_names=route.street_names, - ) - for route in filtered_routes - if route.distance is not None - ] - - if len(filtered_routes) < 1: - _LOGGER.warning("No routes found") - return None - except WRCError as exp: - _LOGGER.warning("Error on retrieving data: %s", exp) - return None - - else: - return filtered_routes - - async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/waze_travel_time/coordinator.py b/homeassistant/components/waze_travel_time/coordinator.py new file mode 100644 index 00000000000..23dfea86ed2 --- /dev/null +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -0,0 +1,245 @@ +"""The Waze Travel Time data coordinator.""" + +import asyncio +from collections.abc import Collection +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Literal + +from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.location import find_coordinates +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import ( + CONF_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS, + CONF_DESTINATION, + CONF_EXCL_FILTER, + CONF_INCL_FILTER, + CONF_ORIGIN, + CONF_REALTIME, + CONF_UNITS, + CONF_VEHICLE_TYPE, + DOMAIN, + IMPERIAL_UNITS, + SEMAPHORE, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=5) + +SECONDS_BETWEEN_API_CALLS = 0.5 + + +async def async_get_travel_times( + client: WazeRouteCalculator, + origin: str, + destination: str, + vehicle_type: str, + avoid_toll_roads: bool, + avoid_subscription_roads: bool, + avoid_ferries: bool, + realtime: bool, + units: Literal["metric", "imperial"] = "metric", + incl_filters: Collection[str] | None = None, + excl_filters: Collection[str] | None = None, +) -> list[CalcRoutesResponse]: + """Get all available routes.""" + + incl_filters = incl_filters or () + excl_filters = excl_filters or () + + _LOGGER.debug( + "Getting update for origin: %s destination: %s", + origin, + destination, + ) + routes = [] + vehicle_type = "" if vehicle_type.upper() == "CAR" else vehicle_type.upper() + try: + routes = await client.calc_routes( + origin, + destination, + vehicle_type=vehicle_type, + avoid_toll_roads=avoid_toll_roads, + avoid_subscription_roads=avoid_subscription_roads, + avoid_ferries=avoid_ferries, + real_time=realtime, + alternatives=3, + ) + + if len(routes) < 1: + _LOGGER.warning("No routes found") + return routes + + _LOGGER.debug("Got routes: %s", routes) + + incl_routes: list[CalcRoutesResponse] = [] + + def should_include_route(route: CalcRoutesResponse) -> bool: + if len(incl_filters) < 1: + return True + should_include = any( + street_name in incl_filters or "" in incl_filters + for street_name in route.street_names + ) + if not should_include: + _LOGGER.debug( + "Excluding route [%s], because no inclusive filter matched any streetname", + route.name, + ) + return False + return True + + incl_routes = [route for route in routes if should_include_route(route)] + + filtered_routes: list[CalcRoutesResponse] = [] + + def should_exclude_route(route: CalcRoutesResponse) -> bool: + for street_name in route.street_names: + for excl_filter in excl_filters: + if excl_filter == street_name: + _LOGGER.debug( + "Excluding route, because exclusive filter [%s] matched streetname: %s", + excl_filter, + route.name, + ) + return True + return False + + filtered_routes = [ + route for route in incl_routes if not should_exclude_route(route) + ] + + if len(filtered_routes) < 1: + _LOGGER.warning("No routes matched your filters") + return filtered_routes + + if units == IMPERIAL_UNITS: + filtered_routes = [ + CalcRoutesResponse( + name=route.name, + distance=DistanceConverter.convert( + route.distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES + ), + duration=route.duration, + street_names=route.street_names, + ) + for route in filtered_routes + if route.distance is not None + ] + + except WRCError as exp: + raise UpdateFailed(f"Error on retrieving data: {exp}") from exp + + else: + return filtered_routes + + +@dataclass +class WazeTravelTimeData: + """WazeTravelTime data class.""" + + origin: str + destination: str + duration: float | None + distance: float | None + route: str | None + + +class WazeTravelTimeCoordinator(DataUpdateCoordinator[WazeTravelTimeData]): + """Waze Travel Time DataUpdateCoordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: WazeRouteCalculator, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=config_entry, + update_interval=SCAN_INTERVAL, + ) + self.client = client + self._origin = config_entry.data[CONF_ORIGIN] + self._destination = config_entry.data[CONF_DESTINATION] + + async def _async_update_data(self) -> WazeTravelTimeData: + """Get the latest data from Waze.""" + origin_coordinates = find_coordinates(self.hass, self._origin) + destination_coordinates = find_coordinates(self.hass, self._destination) + + _LOGGER.debug( + "Fetching Route for %s, from %s to %s", + self.config_entry.title, + self._origin, + self._destination, + ) + await self.hass.data[DOMAIN][SEMAPHORE].acquire() + try: + if origin_coordinates is None or destination_coordinates is None: + raise UpdateFailed("Unable to determine origin or destination") + + # Grab options on every update + incl_filter = self.config_entry.options[CONF_INCL_FILTER] + excl_filter = self.config_entry.options[CONF_EXCL_FILTER] + realtime = self.config_entry.options[CONF_REALTIME] + vehicle_type = self.config_entry.options[CONF_VEHICLE_TYPE] + avoid_toll_roads = self.config_entry.options[CONF_AVOID_TOLL_ROADS] + avoid_subscription_roads = self.config_entry.options[ + CONF_AVOID_SUBSCRIPTION_ROADS + ] + avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] + routes = await async_get_travel_times( + self.client, + origin_coordinates, + destination_coordinates, + vehicle_type, + avoid_toll_roads, + avoid_subscription_roads, + avoid_ferries, + realtime, + self.config_entry.options[CONF_UNITS], + incl_filter, + excl_filter, + ) + if len(routes) < 1: + travel_data = WazeTravelTimeData( + origin=origin_coordinates, + destination=destination_coordinates, + duration=None, + distance=None, + route=None, + ) + + else: + route = routes[0] + + travel_data = WazeTravelTimeData( + origin=origin_coordinates, + destination=destination_coordinates, + duration=route.duration, + distance=route.distance, + route=route.name, + ) + + await asyncio.sleep(SECONDS_BETWEEN_API_CALLS) + + finally: + self.hass.data[DOMAIN][SEMAPHORE].release() + + return travel_data diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 1f21cc2ea78..c1323ce9397 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -2,56 +2,22 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -import logging from typing import Any -import httpx -from pywaze.route_calculator import WazeRouteCalculator - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_NAME, - CONF_REGION, - EVENT_HOMEASSISTANT_STARTED, - UnitOfTime, -) -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.const import CONF_NAME, UnitOfTime +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.location import find_coordinates +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import async_get_travel_times -from .const import ( - CONF_AVOID_FERRIES, - CONF_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_TOLL_ROADS, - CONF_DESTINATION, - CONF_EXCL_FILTER, - CONF_INCL_FILTER, - CONF_ORIGIN, - CONF_REALTIME, - CONF_UNITS, - CONF_VEHICLE_TYPE, - DEFAULT_NAME, - DOMAIN, - SEMAPHORE, -) - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=5) - -PARALLEL_UPDATES = 1 - -SECONDS_BETWEEN_API_CALLS = 0.5 +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import WazeTravelTimeCoordinator async def async_setup_entry( @@ -60,27 +26,20 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Waze travel time sensor entry.""" - destination = config_entry.data[CONF_DESTINATION] - origin = config_entry.data[CONF_ORIGIN] - region = config_entry.data[CONF_REGION] name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) + coordinator = config_entry.runtime_data - data = WazeTravelTimeData( - region, - get_async_client(hass), - config_entry, - ) - - sensor = WazeTravelTime(config_entry.entry_id, name, origin, destination, data) + sensor = WazeTravelTimeSensor(config_entry.entry_id, name, coordinator) async_add_entities([sensor], False) -class WazeTravelTime(SensorEntity): +class WazeTravelTimeSensor(CoordinatorEntity[WazeTravelTimeCoordinator], SensorEntity): """Representation of a Waze travel time sensor.""" _attr_attribution = "Powered by Waze" _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_suggested_display_precision = 0 _attr_device_class = SensorDeviceClass.DURATION _attr_state_class = SensorStateClass.MEASUREMENT _attr_device_info = DeviceInfo( @@ -95,119 +54,30 @@ class WazeTravelTime(SensorEntity): self, unique_id: str, name: str, - origin: str, - destination: str, - waze_data: WazeTravelTimeData, + coordinator: WazeTravelTimeCoordinator, ) -> None: """Initialize the Waze travel time sensor.""" + super().__init__(coordinator) self._attr_unique_id = unique_id - self._waze_data = waze_data self._attr_name = name - self._origin = origin - self._destination = destination - self._state = None - - async def async_added_to_hass(self) -> None: - """Handle when entity is added.""" - if self.hass.state is not CoreState.running: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, self.first_update - ) - else: - await self.first_update() @property def native_value(self) -> float | None: """Return the state of the sensor.""" - if self._waze_data.duration is not None: - return round(self._waze_data.duration) - + if self.coordinator.data is not None: + return self.coordinator.data.duration return None @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the last update.""" - if self._waze_data.duration is None: + if self.coordinator.data is None: return None return { - "duration": self._waze_data.duration, - "distance": self._waze_data.distance, - "route": self._waze_data.route, - "origin": self._waze_data.origin, - "destination": self._waze_data.destination, + "duration": self.coordinator.data.duration, + "distance": self.coordinator.data.distance, + "route": self.coordinator.data.route, + "origin": self.coordinator.data.origin, + "destination": self.coordinator.data.destination, } - - async def first_update(self, _=None) -> None: - """Run first update and write state.""" - await self.async_update() - self.async_write_ha_state() - - async def async_update(self) -> None: - """Fetch new state data for the sensor.""" - _LOGGER.debug("Fetching Route for %s", self._attr_name) - self._waze_data.origin = find_coordinates(self.hass, self._origin) - self._waze_data.destination = find_coordinates(self.hass, self._destination) - await self.hass.data[DOMAIN][SEMAPHORE].acquire() - try: - await self._waze_data.async_update() - await asyncio.sleep(SECONDS_BETWEEN_API_CALLS) - finally: - self.hass.data[DOMAIN][SEMAPHORE].release() - - -class WazeTravelTimeData: - """WazeTravelTime Data object.""" - - def __init__( - self, region: str, client: httpx.AsyncClient, config_entry: ConfigEntry - ) -> None: - """Set up WazeRouteCalculator.""" - self.config_entry = config_entry - self.client = WazeRouteCalculator(region=region, client=client) - self.origin: str | None = None - self.destination: str | None = None - self.duration = None - self.distance = None - self.route = None - - async def async_update(self): - """Update WazeRouteCalculator Sensor.""" - _LOGGER.debug( - "Getting update for origin: %s destination: %s", - self.origin, - self.destination, - ) - if self.origin is not None and self.destination is not None: - # Grab options on every update - incl_filter = self.config_entry.options[CONF_INCL_FILTER] - excl_filter = self.config_entry.options[CONF_EXCL_FILTER] - realtime = self.config_entry.options[CONF_REALTIME] - vehicle_type = self.config_entry.options[CONF_VEHICLE_TYPE] - avoid_toll_roads = self.config_entry.options[CONF_AVOID_TOLL_ROADS] - avoid_subscription_roads = self.config_entry.options[ - CONF_AVOID_SUBSCRIPTION_ROADS - ] - avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] - routes = await async_get_travel_times( - self.client, - self.origin, - self.destination, - vehicle_type, - avoid_toll_roads, - avoid_subscription_roads, - avoid_ferries, - realtime, - self.config_entry.options[CONF_UNITS], - incl_filter, - excl_filter, - ) - if routes: - route = routes[0] - else: - _LOGGER.warning("No routes found") - return - - self.duration = route.duration - self.distance = route.distance - self.route = route.name diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 8f8de694b2d..c57f5470b04 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -27,8 +27,8 @@ "data": { "units": "Units", "vehicle_type": "Vehicle type", - "incl_filter": "Exact streetname which must be part of the selected route", - "excl_filter": "Exact streetname which must NOT be part of the selected route", + "incl_filter": "Exact street name which must be part of the selected route", + "excl_filter": "Exact street name which must NOT be part of the selected route", "realtime": "Realtime travel time?", "avoid_toll_roads": "Avoid toll roads?", "avoid_ferries": "Avoid ferries?", @@ -103,12 +103,12 @@ "description": "Whether to avoid subscription roads." }, "incl_filter": { - "name": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]", - "description": "Exact streetname which must be part of the selected route." + "name": "Streets to include", + "description": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]" }, "excl_filter": { - "name": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]", - "description": "Exact streetname which must NOT be part of the selected route." + "name": "Streets to exclude", + "description": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]" } } } diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index 94c65b7c0a1..1b3679b9113 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -2,30 +2,107 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +import asyncio +from dataclasses import dataclass -from .const import DOMAIN -from .coordinator import WeatherFlowCloudDataUpdateCoordinator +from weatherflow4py.api import WeatherFlowRestAPI +from weatherflow4py.ws import WeatherFlowWebsocketAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .coordinator import ( + WeatherFlowCloudUpdateCoordinatorREST, + WeatherFlowObservationCoordinator, + WeatherFlowWindCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] +@dataclass +class WeatherFlowCoordinators: + """Data Class for Entry Data.""" + + rest: WeatherFlowCloudUpdateCoordinatorREST + wind: WeatherFlowWindCoordinator + observation: WeatherFlowObservationCoordinator + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WeatherFlowCloud from a config entry.""" - data_coordinator = WeatherFlowCloudDataUpdateCoordinator(hass, entry) - await data_coordinator.async_config_entry_first_refresh() + LOGGER.debug("Initializing WeatherFlowCloudDataUpdateCoordinatorREST coordinator") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator + rest_api = WeatherFlowRestAPI( + api_token=entry.data[CONF_API_TOKEN], session=async_get_clientsession(hass) + ) + + stations = await rest_api.async_get_stations() + + # Define Rest Coordinator + rest_data_coordinator = WeatherFlowCloudUpdateCoordinatorREST( + hass=hass, config_entry=entry, rest_api=rest_api, stations=stations + ) + + # Initialize the stations + await rest_data_coordinator.async_config_entry_first_refresh() + + # Construct Websocket Coordinators + LOGGER.debug("Initializing websocket coordinators") + websocket_device_ids = rest_data_coordinator.device_ids + + # Build API once + websocket_api = WeatherFlowWebsocketAPI( + access_token=entry.data[CONF_API_TOKEN], device_ids=websocket_device_ids + ) + + websocket_observation_coordinator = WeatherFlowObservationCoordinator( + hass=hass, + config_entry=entry, + rest_api=rest_api, + websocket_api=websocket_api, + stations=stations, + ) + + websocket_wind_coordinator = WeatherFlowWindCoordinator( + hass=hass, + config_entry=entry, + rest_api=rest_api, + websocket_api=websocket_api, + stations=stations, + ) + + # Run setup method + await asyncio.gather( + websocket_wind_coordinator.async_setup(), + websocket_observation_coordinator.async_setup(), + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = WeatherFlowCoordinators( + rest_data_coordinator, + websocket_wind_coordinator, + websocket_observation_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Websocket disconnect handler + async def _async_disconnect_websocket() -> None: + await websocket_api.stop_all_listeners() + await websocket_api.close() + + # Register a websocket shutdown handler + entry.async_on_unload(_async_disconnect_websocket) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/weatherflow_cloud/config_flow.py b/homeassistant/components/weatherflow_cloud/config_flow.py index bdd3003e6b6..41ac59b0e4b 100644 --- a/homeassistant/components/weatherflow_cloud/config_flow.py +++ b/homeassistant/components/weatherflow_cloud/config_flow.py @@ -49,10 +49,11 @@ class WeatherFlowCloudConfigFlow(ConfigFlow, domain=DOMAIN): errors = await _validate_api_token(api_token) if not errors: # Update the existing entry and abort + existing_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( - self._get_reauth_entry(), + existing_entry, data={CONF_API_TOKEN: api_token}, - reload_even_if_entry_is_unchanged=False, + reason="reauth_successful", ) return self.async_show_form( diff --git a/homeassistant/components/weatherflow_cloud/const.py b/homeassistant/components/weatherflow_cloud/const.py index 24ae2f3a3cb..084010721af 100644 --- a/homeassistant/components/weatherflow_cloud/const.py +++ b/homeassistant/components/weatherflow_cloud/const.py @@ -5,7 +5,7 @@ import logging DOMAIN = "weatherflow_cloud" LOGGER = logging.getLogger(__package__) -ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest REST Api" +ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest API" MANUFACTURER = "WeatherFlow" STATE_MAP = { @@ -29,3 +29,6 @@ STATE_MAP = { "thunderstorm": "lightning", "windy": "windy", } + +WEBSOCKET_API = "Websocket API" +REST_API = "REST API" diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index b6d2bfd5af2..ed3f8445110 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -1,46 +1,207 @@ -"""Data coordinator for WeatherFlow Cloud Data.""" +"""Improved coordinator design with better type safety.""" +from abc import ABC, abstractmethod from datetime import timedelta +from typing import Generic, TypeVar from aiohttp import ClientResponseError from weatherflow4py.api import WeatherFlowRestAPI +from weatherflow4py.models.rest.stations import StationsResponseREST from weatherflow4py.models.rest.unified import WeatherFlowDataREST +from weatherflow4py.models.ws.obs import WebsocketObservation +from weatherflow4py.models.ws.types import EventType +from weatherflow4py.models.ws.websocket_request import ( + ListenStartMessage, + RapidWindListenStartMessage, +) +from weatherflow4py.models.ws.websocket_response import ( + EventDataRapidWind, + ObservationTempestWS, + RapidWindWS, +) +from weatherflow4py.ws import WeatherFlowWebsocketAPI from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import client_context from .const import DOMAIN, LOGGER +T = TypeVar("T") -class WeatherFlowCloudDataUpdateCoordinator( - DataUpdateCoordinator[dict[int, WeatherFlowDataREST]] -): - """Class to manage fetching REST Based WeatherFlow Forecast data.""" - config_entry: ConfigEntry +class BaseWeatherFlowCoordinator(DataUpdateCoordinator[dict[int, T]], ABC, Generic[T]): + """Base class for WeatherFlow coordinators.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + rest_api: WeatherFlowRestAPI, + stations: StationsResponseREST, + update_interval: timedelta | None = None, + always_update: bool = False, + ) -> None: + """Initialize Coordinator.""" + self._token = rest_api.api_token + self._rest_api = rest_api + self.stations = stations + self.device_to_station_map = stations.device_station_map + self.device_ids = list(stations.device_station_map.keys()) - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Initialize global WeatherFlow forecast data updater.""" - self.weather_api = WeatherFlowRestAPI( - api_token=config_entry.data[CONF_API_TOKEN] - ) super().__init__( hass, LOGGER, config_entry=config_entry, name=DOMAIN, + always_update=always_update, + update_interval=update_interval, + ) + + @abstractmethod + def get_station_name(self, station_id: int) -> str: + """Get station name for the given station ID.""" + + +class WeatherFlowCloudUpdateCoordinatorREST( + BaseWeatherFlowCoordinator[WeatherFlowDataREST] +): + """Class to manage fetching REST Based WeatherFlow Forecast data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + rest_api: WeatherFlowRestAPI, + stations: StationsResponseREST, + ) -> None: + """Initialize global WeatherFlow forecast data updater.""" + super().__init__( + hass, + config_entry, + rest_api, + stations, update_interval=timedelta(seconds=60), + always_update=True, ) async def _async_update_data(self) -> dict[int, WeatherFlowDataREST]: - """Fetch data from WeatherFlow Forecast.""" + """Update rest data.""" try: - async with self.weather_api: - return await self.weather_api.get_all_data() + async with self._rest_api: + return await self._rest_api.get_all_data() except ClientResponseError as err: if err.status == 401: raise ConfigEntryAuthFailed(err) from err raise UpdateFailed(f"Update failed: {err}") from err + + def get_station(self, station_id: int) -> WeatherFlowDataREST: + """Return station for id.""" + return self.data[station_id] + + def get_station_name(self, station_id: int) -> str: + """Return station name for id.""" + return self.data[station_id].station.name + + +class BaseWebsocketCoordinator( + BaseWeatherFlowCoordinator[dict[int, T | None]], ABC, Generic[T] +): + """Base class for websocket coordinators.""" + + _event_type: EventType + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + rest_api: WeatherFlowRestAPI, + websocket_api: WeatherFlowWebsocketAPI, + stations: StationsResponseREST, + ) -> None: + """Initialize Coordinator.""" + super().__init__( + hass=hass, config_entry=config_entry, rest_api=rest_api, stations=stations + ) + + self.websocket_api = websocket_api + + # Configure the websocket data structure + self._ws_data: dict[int, dict[int, T | None]] = { + station: dict.fromkeys(devices) + for station, devices in self.stations.station_device_map.items() + } + + async def async_setup(self) -> None: + """Set up the websocket connection.""" + await self.websocket_api.connect(client_context()) + self.websocket_api.register_callback( + message_type=self._event_type, + callback=self._handle_websocket_message, + ) + + # Subscribe to messages for all devices + for device_id in self.device_ids: + message = self._create_listen_message(device_id) + await self.websocket_api.send_message(message) + + @abstractmethod + def _create_listen_message(self, device_id: int): + """Create the appropriate listen message for this coordinator type.""" + + @abstractmethod + async def _handle_websocket_message(self, data) -> None: + """Handle incoming websocket data.""" + + def get_station(self, station_id: int): + """Return station for id.""" + return self.stations.stations[station_id] + + def get_station_name(self, station_id: int) -> str: + """Return station name for id.""" + return self.stations.station_map[station_id].name or "" + + +class WeatherFlowWindCoordinator(BaseWebsocketCoordinator[EventDataRapidWind]): + """Coordinator specifically for rapid wind data.""" + + _event_type = EventType.RAPID_WIND + + def _create_listen_message(self, device_id: int) -> RapidWindListenStartMessage: + """Create rapid wind listen message.""" + return RapidWindListenStartMessage(device_id=str(device_id)) + + async def _handle_websocket_message(self, data: RapidWindWS) -> None: + """Handle rapid wind websocket data.""" + device_id = data.device_id + station_id = self.device_to_station_map[device_id] + + # Extract the observation data from the RapidWindWS message + self._ws_data[station_id][device_id] = data.ob + self.async_set_updated_data(self._ws_data) + + +class WeatherFlowObservationCoordinator(BaseWebsocketCoordinator[WebsocketObservation]): + """Coordinator specifically for observation data.""" + + _event_type = EventType.OBSERVATION + + def _create_listen_message(self, device_id: int) -> ListenStartMessage: + """Create observation listen message.""" + return ListenStartMessage(device_id=str(device_id)) + + async def _handle_websocket_message(self, data: ObservationTempestWS) -> None: + """Handle observation websocket data.""" + device_id = data.device_id + station_id = self.device_to_station_map[device_id] + + # For observations, the data IS the observation + self._ws_data[station_id][device_id] = data + self.async_set_updated_data(self._ws_data) + + +# Type aliases for better readability +type WeatherFlowWindCallback = WeatherFlowWindCoordinator +type WeatherFlowObservationCallback = WeatherFlowObservationCoordinator diff --git a/homeassistant/components/weatherflow_cloud/entity.py b/homeassistant/components/weatherflow_cloud/entity.py index 46077ab0870..4ac1da92996 100644 --- a/homeassistant/components/weatherflow_cloud/entity.py +++ b/homeassistant/components/weatherflow_cloud/entity.py @@ -1,23 +1,21 @@ -"""Base entity class for WeatherFlow Cloud integration.""" - -from weatherflow4py.models.rest.unified import WeatherFlowDataREST +"""Entity definition.""" from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_ATTRIBUTION, DOMAIN, MANUFACTURER -from .coordinator import WeatherFlowCloudDataUpdateCoordinator +from .coordinator import BaseWeatherFlowCoordinator -class WeatherFlowCloudEntity(CoordinatorEntity[WeatherFlowCloudDataUpdateCoordinator]): - """Base entity class to use for everything.""" +class WeatherFlowCloudEntity[T](CoordinatorEntity[BaseWeatherFlowCoordinator[T]]): + """Base entity class for WeatherFlow Cloud integration.""" _attr_attribution = ATTR_ATTRIBUTION _attr_has_entity_name = True def __init__( self, - coordinator: WeatherFlowCloudDataUpdateCoordinator, + coordinator: BaseWeatherFlowCoordinator[T], station_id: int, ) -> None: """Class initializer.""" @@ -25,14 +23,9 @@ class WeatherFlowCloudEntity(CoordinatorEntity[WeatherFlowCloudDataUpdateCoordin self.station_id = station_id self._attr_device_info = DeviceInfo( - name=self.station.station.name, + name=coordinator.get_station_name(station_id), entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, str(station_id))}, manufacturer=MANUFACTURER, configuration_url=f"https://tempestwx.com/station/{station_id}/grid", ) - - @property - def station(self) -> WeatherFlowDataREST: - """Individual Station data.""" - return self.coordinator.data[self.station_id] diff --git a/homeassistant/components/weatherflow_cloud/icons.json b/homeassistant/components/weatherflow_cloud/icons.json index 19e6ac56821..a5759d8b810 100644 --- a/homeassistant/components/weatherflow_cloud/icons.json +++ b/homeassistant/components/weatherflow_cloud/icons.json @@ -1,11 +1,17 @@ { "entity": { "sensor": { + "air_density": { + "default": "mdi:format-line-weight" + }, "air_temperature": { "default": "mdi:thermometer" }, - "air_density": { - "default": "mdi:format-line-weight" + "barometric_pressure": { + "default": "mdi:gauge" + }, + "dew_point": { + "default": "mdi:water-percent" }, "feels_like": { "default": "mdi:thermometer" @@ -13,12 +19,6 @@ "heat_index": { "default": "mdi:sun-thermometer" }, - "wet_bulb_temperature": { - "default": "mdi:thermometer-water" - }, - "wet_bulb_globe_temperature": { - "default": "mdi:thermometer-water" - }, "lightning_strike_count": { "default": "mdi:lightning-bolt" }, @@ -34,8 +34,98 @@ "lightning_strike_last_epoch": { "default": "mdi:lightning-bolt" }, + + "precip_accum_local_day": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + "precip_accum_local_day_final": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + "precip_accum_local_yesterday": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + "precip_accum_local_yesterday_final": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + + "precip_minutes_local_day": { + "default": "mdi:umbrella-closed", + "range": { + "1": "mdi:umbrella" + } + }, + "precip_minutes_local_yesterday": { + "default": "mdi:umbrella-closed", + "range": { + "1": "mdi:umbrella" + } + }, + "precip_minutes_local_yesterday_final": { + "default": "mdi:umbrella-closed", + "range": { + "1": "mdi:umbrella" + } + }, + + "precip_analysis_type_yesterday": { + "default": "mdi:radar", + "state": { + "rain": "mdi:weather-rainy", + "snow": "mdi:weather-snowy", + "rain_snow": "mdi:weather-snoy-rainy", + "lightning": "mdi:weather-lightning-rainy" + } + }, + "sea_level_pressure": { + "default": "mdi:gauge" + }, + "wet_bulb_globe_temperature": { + "default": "mdi:thermometer-water" + }, + "wet_bulb_temperature": { + "default": "mdi:thermometer-water" + }, + "wind_avg": { + "default": "mdi:weather-windy" + }, "wind_chill": { "default": "mdi:snowflake-thermometer" + }, + + "wind_direction": { + "default": "mdi:compass", + "range": { + "0": "mdi:arrow-up", + "22.5": "mdi:arrow-top-right", + "67.5": "mdi:arrow-right", + "112.5": "mdi:arrow-bottom-right", + "157.5": "mdi:arrow-down", + "202.5": "mdi:arrow-bottom-left", + "247.5": "mdi:arrow-left", + "292.5": "mdi:arrow-top-left", + "337.5": "mdi:arrow-up" + } + }, + "wind_gust": { + "default": "mdi:weather-dust" + }, + "wind_lull": { + "default": "mdi:weather-windy-variant" + }, + "wind_sample_interval": { + "default": "mdi:timer-outline" } } } diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 9ffa457a355..d39e373312d 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.3.1"] + "requirements": ["weatherflow4py==1.4.1"] } diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index d2c62b5f281..ec094448519 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -2,11 +2,17 @@ from __future__ import annotations +from abc import ABC from collections.abc import Callable from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import date, datetime +from decimal import Decimal from weatherflow4py.models.rest.observation import Observation +from weatherflow4py.models.ws.websocket_response import ( + EventDataRapidWind, + WebsocketObservation, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,15 +21,32 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfLength, UnitOfPressure, UnitOfTemperature +from homeassistant.const import ( + EntityCategory, + UnitOfLength, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import UTC +from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators from .const import DOMAIN -from .coordinator import WeatherFlowCloudDataUpdateCoordinator +from .coordinator import WeatherFlowObservationCoordinator, WeatherFlowWindCoordinator from .entity import WeatherFlowCloudEntity +PRECIPITATION_TYPE = { + 0: "none", + 1: "rain", + 2: "snow", + 3: "sleet", + 4: "storm", +} + @dataclass(frozen=True, kw_only=True) class WeatherFlowCloudSensorEntityDescription( @@ -34,6 +57,87 @@ class WeatherFlowCloudSensorEntityDescription( value_fn: Callable[[Observation], StateType | datetime] +@dataclass(frozen=True, kw_only=True) +class WeatherFlowCloudSensorEntityDescriptionWebsocketWind( + SensorEntityDescription, +): + """Describes a weatherflow sensor.""" + + value_fn: Callable[[EventDataRapidWind], StateType | datetime] + + +@dataclass(frozen=True, kw_only=True) +class WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + SensorEntityDescription, +): + """Describes a weatherflow sensor.""" + + value_fn: Callable[[WebsocketObservation], StateType | datetime] + + +WEBSOCKET_WIND_SENSORS: tuple[ + WeatherFlowCloudSensorEntityDescriptionWebsocketWind, ... +] = ( + WeatherFlowCloudSensorEntityDescriptionWebsocketWind( + key="wind_speed", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_speed_meters_per_second, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketWind( + key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, + translation_key="wind_direction", + value_fn=lambda data: data.wind_direction_degrees, + native_unit_of_measurement="°", + ), +) + +WEBSOCKET_OBSERVATION_SENSORS: tuple[ + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation, ... +] = ( + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_lull", + translation_key="wind_lull", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_lull, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_gust", + translation_key="wind_gust", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_gust, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_avg", + translation_key="wind_avg", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_avg, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_sample_interval", + translation_key="wind_sample_interval", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.wind_sample_interval, + ), +) + + WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( # Air Sensors WeatherFlowCloudSensorEntityDescription( @@ -127,6 +231,81 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=3, ), + # Rain Sensors + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_last_1hr", + translation_key="precip_accum_last_1hr", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_last_1hr, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_day", + translation_key="precip_accum_local_day", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_day, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_day_final", + translation_key="precip_accum_local_day_final", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_day_final, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_yesterday", + translation_key="precip_accum_local_yesterday", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_yesterday, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_yesterday_final", + translation_key="precip_accum_local_yesterday_final", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_yesterday_final, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_analysis_type_yesterday", + translation_key="precip_analysis_type_yesterday", + device_class=SensorDeviceClass.ENUM, + options=["none", "rain", "snow", "sleet", "storm"], + suggested_display_precision=1, + value_fn=lambda data: PRECIPITATION_TYPE.get( + data.precip_analysis_type_yesterday + ), + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_minutes_local_day", + translation_key="precip_minutes_local_day", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=1, + value_fn=lambda data: data.precip_minutes_local_day, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_minutes_local_yesterday", + translation_key="precip_minutes_local_yesterday", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=1, + value_fn=lambda data: data.precip_minutes_local_yesterday, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_minutes_local_yesterday_final", + translation_key="precip_minutes_local_yesterday_final", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=1, + value_fn=lambda data: data.precip_minutes_local_yesterday_final, + ), # Lightning Sensors WeatherFlowCloudSensorEntityDescription( key="lightning_strike_count", @@ -176,35 +355,133 @@ async def async_setup_entry( ) -> None: """Set up WeatherFlow sensors based on a config entry.""" - coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id + coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][entry.entry_id] + rest_coordinator = coordinators.rest + wind_coordinator = coordinators.wind # Now properly typed + observation_coordinator = coordinators.observation # Now properly typed + + entities: list[SensorEntity] = [ + WeatherFlowCloudSensorREST(rest_coordinator, sensor_description, station_id) + for station_id in rest_coordinator.data + for sensor_description in WF_SENSORS ] - async_add_entities( - WeatherFlowCloudSensor(coordinator, sensor_description, station_id) - for station_id in coordinator.data - for sensor_description in WF_SENSORS + entities.extend( + WeatherFlowWebsocketSensorWind( + coordinator=wind_coordinator, + description=sensor_description, + station_id=station_id, + device_id=device_id, + ) + for station_id in wind_coordinator.stations.station_outdoor_device_map + for device_id in wind_coordinator.stations.station_outdoor_device_map[ + station_id + ] + for sensor_description in WEBSOCKET_WIND_SENSORS ) + entities.extend( + WeatherFlowWebsocketSensorObservation( + coordinator=observation_coordinator, + description=sensor_description, + station_id=station_id, + device_id=device_id, + ) + for station_id in observation_coordinator.stations.station_outdoor_device_map + for device_id in observation_coordinator.stations.station_outdoor_device_map[ + station_id + ] + for sensor_description in WEBSOCKET_OBSERVATION_SENSORS + ) + async_add_entities(entities) -class WeatherFlowCloudSensor(WeatherFlowCloudEntity, SensorEntity): - """Implementation of a WeatherFlow sensor.""" - entity_description: WeatherFlowCloudSensorEntityDescription +class WeatherFlowSensorBase(WeatherFlowCloudEntity, SensorEntity, ABC): + """Common base class.""" def __init__( self, - coordinator: WeatherFlowCloudDataUpdateCoordinator, - description: WeatherFlowCloudSensorEntityDescription, + coordinator: ( + WeatherFlowCloudUpdateCoordinatorREST + | WeatherFlowWindCoordinator + | WeatherFlowObservationCoordinator + ), + description: ( + WeatherFlowCloudSensorEntityDescription + | WeatherFlowCloudSensorEntityDescriptionWebsocketWind + | WeatherFlowCloudSensorEntityDescriptionWebsocketObservation + ), station_id: int, + device_id: int | None = None, ) -> None: - """Initialize the sensor.""" - # Initialize the Entity Class + """Initialize a sensor.""" super().__init__(coordinator, station_id) + self.station_id = station_id + self.device_id = device_id self.entity_description = description - self._attr_unique_id = f"{station_id}_{description.key}" + self._attr_unique_id = self._generate_unique_id() + + def _generate_unique_id(self) -> str: + """Generate a unique ID for the sensor.""" + if self.device_id is not None: + return f"{self.station_id}_{self.device_id}_{self.entity_description.key}" + return f"{self.station_id}_{self.entity_description.key}" + + @property + def available(self) -> bool: + """Get if available.""" + + if not super().available: + return False + + if self.device_id is not None: + # Websocket sensors - have Device IDs + return bool( + self.coordinator.data + and self.coordinator.data[self.station_id][self.device_id] is not None + ) + + return True + + +class WeatherFlowWebsocketSensorObservation(WeatherFlowSensorBase): + """Class for Websocket Observations.""" + + entity_description: WeatherFlowCloudSensorEntityDescriptionWebsocketObservation + + @property + def native_value(self) -> StateType | date | datetime | Decimal: + """Return the native value.""" + data = self.coordinator.data[self.station_id][self.device_id] + return self.entity_description.value_fn(data) + + +class WeatherFlowWebsocketSensorWind(WeatherFlowSensorBase): + """Class for wind over websockets.""" + + entity_description: WeatherFlowCloudSensorEntityDescriptionWebsocketWind @property def native_value(self) -> StateType | datetime: - """Return the state of the sensor.""" - return self.entity_description.value_fn(self.station.observation.obs[0]) + """Return the native value.""" + + # This data is often invalid at starutp. + if self.coordinator.data is not None: + data = self.coordinator.data[self.station_id][self.device_id] + return self.entity_description.value_fn(data) + return None + + +class WeatherFlowCloudSensorREST(WeatherFlowSensorBase): + """Class for a REST based sensor.""" + + entity_description: WeatherFlowCloudSensorEntityDescription + + coordinator: WeatherFlowCloudUpdateCoordinatorREST + + @property + def native_value(self) -> StateType | datetime: + """Return the native value.""" + return self.entity_description.value_fn( + self.coordinator.data[self.station_id].observation.obs[0] + ) diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index d22c62a030c..5b628e9f5c8 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -32,13 +32,15 @@ "barometric_pressure": { "name": "Pressure barometric" }, - "sea_level_pressure": { - "name": "Pressure sea level" - }, - "dew_point": { "name": "Dew point" }, + "feels_like": { + "name": "Feels like" + }, + "heat_index": { + "name": "Heat index" + }, "lightning_strike_count": { "name": "Lightning count" }, @@ -54,33 +56,60 @@ "lightning_strike_last_epoch": { "name": "Lightning last strike" }, + "precip_accum_last_1hr": { + "name": "Rain last hour" + }, + "precip_accum_local_day": { + "name": "Precipitation today" + }, + "precip_accum_local_day_final": { + "name": "Nearcast precipitation today" + }, + "precip_accum_local_yesterday": { + "name": "Precipitation yesterday" + }, + "precip_accum_local_yesterday_final": { + "name": "Nearcast precipitation yesterday" + }, + "precip_analysis_type_yesterday": { + "name": "Precipitation type yesterday" + }, + "precip_minutes_local_day": { + "name": "Precipitation duration today" + }, + "precip_minutes_local_yesterday": { + "name": "Precipitation duration yesterday" + }, + "precip_minutes_local_yesterday_final": { + "name": "Nearcast precipitation duration yesterday" + }, + "sea_level_pressure": { + "name": "Pressure sea level" + }, + "wet_bulb_globe_temperature": { + "name": "Wet bulb globe temperature" + }, + "wet_bulb_temperature": { + "name": "Wet bulb temperature" + }, + "wind_avg": { + "name": "Wind speed (avg)" + }, "wind_chill": { "name": "Wind chill" }, "wind_direction": { "name": "Wind direction" }, - "wind_direction_cardinal": { - "name": "Wind direction (cardinal)" - }, "wind_gust": { "name": "Wind gust" }, "wind_lull": { "name": "Wind lull" }, - "feels_like": { - "name": "Feels like" - }, - "heat_index": { - "name": "Heat index" - }, - "wet_bulb_temperature": { - "name": "Wet bulb temperature" - }, - "wet_bulb_globe_temperature": { - "name": "Wet bulb globe temperature" + "wind_sample_interval": { + "name": "Wind sample interval" } } } diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py index 3cb1f477095..1114d84b858 100644 --- a/homeassistant/components/weatherflow_cloud/weather.py +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -19,8 +19,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators from .const import DOMAIN, STATE_MAP -from .coordinator import WeatherFlowCloudDataUpdateCoordinator from .entity import WeatherFlowCloudEntity @@ -30,21 +30,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WeatherFlowWeather(coordinator, station_id=station_id) - for station_id, data in coordinator.data.items() + WeatherFlowWeatherREST(coordinators.rest, station_id=station_id) + for station_id, data in coordinators.rest.data.items() ] ) -class WeatherFlowWeather( +class WeatherFlowWeatherREST( WeatherFlowCloudEntity, - SingleCoordinatorWeatherEntity[WeatherFlowCloudDataUpdateCoordinator], + SingleCoordinatorWeatherEntity[WeatherFlowCloudUpdateCoordinatorREST], ): """Implementation of a WeatherFlow weather condition.""" @@ -59,7 +57,7 @@ class WeatherFlowWeather( def __init__( self, - coordinator: WeatherFlowCloudDataUpdateCoordinator, + coordinator: WeatherFlowCloudUpdateCoordinatorREST, station_id: int, ) -> None: """Initialise the platform with a data instance and station.""" diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index e3e46d2575a..95b20761d09 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,11 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import voluptuous as vol import yarl @@ -65,6 +69,8 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" + except AccessDeniedError: + errors["base"] = "access_denied" except MethodNotSupportedError: errors["base"] = "invalid_method" except Exception: diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index ac6418f1239..689b27bbf66 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "access_denied": "The access to the backup path has been denied. Please check the permissions of the backup path.", "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -35,9 +36,6 @@ "cannot_connect": { "message": "Cannot connect to WebDAV server" }, - "cannot_access_or_create_backup_path": { - "message": "Cannot access or create backup path. Please check the path and permissions." - }, "failed_to_migrate_folder": { "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index c1a1c698f92..b62d7b828af 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -8,6 +8,7 @@ from aiowebostv import WebOsClient, WebOsTvPairError from homeassistant.components import notify as hass_notify from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, @@ -20,13 +21,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - DATA_HASS_CONFIG, - DOMAIN, - PLATFORMS, - WEBOSTV_EXCEPTIONS, -) +from .const import DATA_HASS_CONFIG, DOMAIN, PLATFORMS, WEBOSTV_EXCEPTIONS from .helpers import WebOsTvConfigEntry, update_client_key CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -75,8 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b ) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" client.clear_state_update_callbacks() @@ -88,11 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b return True -async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 80c8fb7f8f2..44711c2b456 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -9,7 +9,11 @@ from urllib.parse import urlparse from aiowebostv import WebOsClient, WebOsTvPairError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -60,7 +64,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -98,7 +102,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} if not self._name: - self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}" + if model_name := client.tv_info.system.get("modelName"): + self._name = f"{DEFAULT_NAME} {model_name}" + else: + self._name = DEFAULT_NAME return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) @@ -194,7 +201,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" def __init__(self, config_entry: WebOsTvConfigEntry) -> None: diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 118ea7b32db..e8774fa24e3 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -13,7 +13,6 @@ DATA_HASS_CONFIG = "hass_config" DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_PAYLOAD = "payload" ATTR_SOUND_OUTPUT = "sound_output" diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 8ac470ae922..f8201fe3bef 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.3"], + "requirements": ["aiowebostv==0.7.5"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 3966cea5e92..a2e9753c172 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -7,13 +7,13 @@ from typing import Any from aiowebostv import WebOsClient from homeassistant.components.notify import ATTR_DATA, BaseNotificationService -from homeassistant.const import ATTR_ICON +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import WebOsTvConfigEntry -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, WEBOSTV_EXCEPTIONS PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index f6d033af632..2f0a413754e 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -12,7 +12,7 @@ } }, "pairing": { - "title": "LG webOS TV Pairing", + "title": "LG webOS TV pairing", "description": "Select **Submit** and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { @@ -37,7 +37,7 @@ "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%]", - "wrong_device": "The configured device is not the same found on this Hostname or IP address." + "wrong_device": "The configured device is not the same found at this hostname or IP address." } }, "options": { @@ -70,7 +70,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of the webostv entities where to run the API method." + "description": "Name(s) of the webOS TV entities where to run the API method." }, "button": { "name": "Button", @@ -92,7 +92,7 @@ }, "payload": { "name": "Payload", - "description": "An optional payload to provide to the endpoint in the format of key value pair(s)." + "description": "An optional payload to provide to the endpoint in the format of key value pairs." } } }, @@ -102,7 +102,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of the webostv entities to change sound output on." + "description": "Name(s) of the webOS TV entities to change sound output on." }, "sound_output": { "name": "Sound output", @@ -134,7 +134,7 @@ "message": "Unknown trigger platform: {platform}" }, "invalid_entity_id": { - "message": "Entity {entity_id} is not a valid webostv entity." + "message": "Entity {entity_id} is not a valid webOS TV entity." }, "source_not_found": { "message": "Source {source} not found in the sources list for {name}." diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 498a986e806..b63e5e14820 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -35,6 +35,10 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, entity, template +from homeassistant.helpers.condition import ( + async_get_all_descriptions as async_get_all_condition_descriptions, + async_subscribe_platform_events as async_subscribe_condition_platform_events, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -52,7 +56,13 @@ from homeassistant.helpers.json import ( json_bytes, json_fragment, ) -from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.service import ( + async_get_all_descriptions as async_get_all_service_descriptions, +) +from homeassistant.helpers.trigger import ( + async_get_all_descriptions as async_get_all_trigger_descriptions, + async_subscribe_platform_events as async_subscribe_trigger_platform_events, +) from homeassistant.loader import ( IntegrationNotFound, async_get_integration, @@ -68,9 +78,11 @@ from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages from .connection import ActiveConnection -from .messages import construct_result_message +from .messages import construct_event_message, construct_result_message +ALL_CONDITION_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_condition_descriptions_json" ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_descriptions_json" _LOGGER = logging.getLogger(__name__) @@ -94,8 +106,10 @@ def async_register_commands( async_reg(hass, handle_ping) async_reg(hass, handle_render_template) async_reg(hass, handle_subscribe_bootstrap_integrations) + async_reg(hass, handle_subscribe_condition_platforms) async_reg(hass, handle_subscribe_events) async_reg(hass, handle_subscribe_trigger) + async_reg(hass, handle_subscribe_trigger_platforms) async_reg(hass, handle_test_condition) async_reg(hass, handle_unsubscribe_events) async_reg(hass, handle_validate_config) @@ -493,9 +507,56 @@ def _send_handle_entities_init_response( ) -async def _async_get_all_descriptions_json(hass: HomeAssistant) -> bytes: +async def _async_get_all_condition_descriptions_json(hass: HomeAssistant) -> bytes: + """Return JSON of descriptions (i.e. user documentation) for all condition.""" + descriptions = await async_get_all_condition_descriptions(hass) + if ALL_CONDITION_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_CONDITION_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(bytes, cached_json_payload) + json_payload = json_bytes( + { + condition: description + for condition, description in descriptions.items() + if description is not None + } + ) + hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload + + +@decorators.websocket_command({vol.Required("type"): "condition_platforms/subscribe"}) +@decorators.async_response +async def handle_subscribe_condition_platforms( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe conditions command.""" + + async def on_new_conditions(new_conditions: set[str]) -> None: + """Forward new conditions to websocket.""" + descriptions = await async_get_all_condition_descriptions(hass) + new_condition_descriptions = {} + for condition in new_conditions: + if (description := descriptions[condition]) is not None: + new_condition_descriptions[condition] = description + if not new_condition_descriptions: + return + connection.send_event(msg["id"], new_condition_descriptions) + + connection.subscriptions[msg["id"]] = async_subscribe_condition_platform_events( + hass, on_new_conditions + ) + connection.send_result(msg["id"]) + conditions_json = await _async_get_all_condition_descriptions_json(hass) + connection.send_message(construct_event_message(msg["id"], conditions_json)) + + +async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes: """Return JSON of descriptions (i.e. user documentation) for all service calls.""" - descriptions = await async_get_all_descriptions(hass) + descriptions = await async_get_all_service_descriptions(hass) if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: cached_descriptions, cached_json_payload = hass.data[ ALL_SERVICE_DESCRIPTIONS_JSON_CACHE @@ -514,10 +575,57 @@ async def handle_get_services( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get services command.""" - payload = await _async_get_all_descriptions_json(hass) + payload = await _async_get_all_service_descriptions_json(hass) connection.send_message(construct_result_message(msg["id"], payload)) +async def _async_get_all_trigger_descriptions_json(hass: HomeAssistant) -> bytes: + """Return JSON of descriptions (i.e. user documentation) for all triggers.""" + descriptions = await async_get_all_trigger_descriptions(hass) + if ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(bytes, cached_json_payload) + json_payload = json_bytes( + { + trigger: description + for trigger, description in descriptions.items() + if description is not None + } + ) + hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload + + +@decorators.websocket_command({vol.Required("type"): "trigger_platforms/subscribe"}) +@decorators.async_response +async def handle_subscribe_trigger_platforms( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe triggers command.""" + + async def on_new_triggers(new_triggers: set[str]) -> None: + """Forward new triggers to websocket.""" + descriptions = await async_get_all_trigger_descriptions(hass) + new_trigger_descriptions = {} + for trigger in new_triggers: + if (description := descriptions[trigger]) is not None: + new_trigger_descriptions[trigger] = description + if not new_trigger_descriptions: + return + connection.send_event(msg["id"], new_trigger_descriptions) + + connection.subscriptions[msg["id"]] = async_subscribe_trigger_platform_events( + hass, on_new_triggers + ) + connection.send_result(msg["id"]) + triggers_json = await _async_get_all_trigger_descriptions_json(hass) + connection.send_message(construct_event_message(msg["id"], triggers_json)) + + @callback @decorators.websocket_command({vol.Required("type"): "get_config"}) def handle_get_config( diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 6ae7de2c4b7..88d29f243d5 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -109,6 +109,19 @@ def event_message(iden: int, event: Any) -> dict[str, Any]: return {"id": iden, "type": "event", "event": event} +def construct_event_message(iden: int, event: bytes) -> bytes: + """Construct an event message JSON.""" + return b"".join( + ( + b'{"id":', + str(iden).encode(), + b',"type":"event","event":', + event, + b"}", + ) + ) + + def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: """Return an event message. diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 6cda83f6419..cb3c8a558b6 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -102,7 +102,6 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): name=wemo.name, update_interval=timedelta(seconds=30), ) - self.hass = hass self.wemo = wemo self.device_id: str | None = None self.device_info = _create_device_info(wemo) diff --git a/homeassistant/components/whirlpool/binary_sensor.py b/homeassistant/components/whirlpool/binary_sensor.py index d8ec373f026..d26f5764313 100644 --- a/homeassistant/components/whirlpool/binary_sensor.py +++ b/homeassistant/components/whirlpool/binary_sensor.py @@ -42,14 +42,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whirlpool binary sensors.""" - entities: list = [] appliances_manager = config_entry.runtime_data - for washer_dryer in appliances_manager.washer_dryers: - entities.extend( - WhirlpoolBinarySensor(washer_dryer, description) - for description in WASHER_DRYER_SENSORS - ) - async_add_entities(entities) + + washer_binary_sensors = [ + WhirlpoolBinarySensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_DRYER_SENSORS + ] + + dryer_binary_sensors = [ + WhirlpoolBinarySensor(dryer, description) + for dryer in appliances_manager.dryers + for description in WASHER_DRYER_SENSORS + ] + + async_add_entities([*washer_binary_sensors, *dryer_binary_sensors]) class WhirlpoolBinarySensor(WhirlpoolEntity, BinarySensorEntity): diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 61d6883d70f..8c216109731 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -70,7 +70,11 @@ async def authenticate( appliances_manager = AppliancesManager(backend_selector, auth, session) await appliances_manager.fetch_appliances() - if not appliances_manager.aircons and not appliances_manager.washer_dryers: + if ( + not appliances_manager.aircons + and not appliances_manager.washers + and not appliances_manager.dryers + ): return "no_appliances" return None diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 09338396de4..fed999b881c 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -37,9 +37,13 @@ async def async_get_config_entry_diagnostics( appliances_manager = config_entry.runtime_data diagnostics_data = { - "washer_dryers": { - wd.name: get_appliance_diagnostics(wd) - for wd in appliances_manager.washer_dryers + "washers": { + washer.name: get_appliance_diagnostics(washer) + for washer in appliances_manager.washers + }, + "dryers": { + dryer.name: get_appliance_diagnostics(dryer) + for dryer in appliances_manager.dryers }, "aircons": { ac.name: get_appliance_diagnostics(ac) for ac in appliances_manager.aircons diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 919fa54c834..2712e6b2f64 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["whirlpool"], "quality_scale": "bronze", - "requirements": ["whirlpool-sixth-sense==0.20.0"] + "requirements": ["whirlpool-sixth-sense==0.21.1"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 6b052834656..1bb825cc18f 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,12 +1,14 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" +from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta from typing import override from whirlpool.appliance import Appliance -from whirlpool.washerdryer import MachineState, WasherDryer +from whirlpool.dryer import Dryer, MachineState as DryerMachineState +from whirlpool.washer import MachineState as WasherMachineState, Washer from homeassistant.components.sensor import ( RestoreSensor, @@ -33,26 +35,49 @@ WASHER_TANK_FILL = { 5: "active", } -WASHER_DRYER_MACHINE_STATE = { - MachineState.Standby: "standby", - MachineState.Setting: "setting", - MachineState.DelayCountdownMode: "delay_countdown", - MachineState.DelayPause: "delay_paused", - MachineState.SmartDelay: "smart_delay", - MachineState.SmartGridPause: "smart_grid_pause", - MachineState.Pause: "pause", - MachineState.RunningMainCycle: "running_maincycle", - MachineState.RunningPostCycle: "running_postcycle", - MachineState.Exceptions: "exception", - MachineState.Complete: "complete", - MachineState.PowerFailure: "power_failure", - MachineState.ServiceDiagnostic: "service_diagnostic_mode", - MachineState.FactoryDiagnostic: "factory_diagnostic_mode", - MachineState.LifeTest: "life_test", - MachineState.CustomerFocusMode: "customer_focus_mode", - MachineState.DemoMode: "demo_mode", - MachineState.HardStopOrError: "hard_stop_or_error", - MachineState.SystemInit: "system_initialize", +WASHER_MACHINE_STATE = { + WasherMachineState.Standby: "standby", + WasherMachineState.Setting: "setting", + WasherMachineState.DelayCountdownMode: "delay_countdown", + WasherMachineState.DelayPause: "delay_paused", + WasherMachineState.SmartDelay: "smart_delay", + WasherMachineState.SmartGridPause: "smart_grid_pause", + WasherMachineState.Pause: "pause", + WasherMachineState.RunningMainCycle: "running_maincycle", + WasherMachineState.RunningPostCycle: "running_postcycle", + WasherMachineState.Exceptions: "exception", + WasherMachineState.Complete: "complete", + WasherMachineState.PowerFailure: "power_failure", + WasherMachineState.ServiceDiagnostic: "service_diagnostic_mode", + WasherMachineState.FactoryDiagnostic: "factory_diagnostic_mode", + WasherMachineState.LifeTest: "life_test", + WasherMachineState.CustomerFocusMode: "customer_focus_mode", + WasherMachineState.DemoMode: "demo_mode", + WasherMachineState.HardStopOrError: "hard_stop_or_error", + WasherMachineState.SystemInit: "system_initialize", +} + +DRYER_MACHINE_STATE = { + DryerMachineState.Standby: "standby", + DryerMachineState.Setting: "setting", + DryerMachineState.DelayCountdownMode: "delay_countdown", + DryerMachineState.DelayPause: "delay_paused", + DryerMachineState.SmartDelay: "smart_delay", + DryerMachineState.SmartGridPause: "smart_grid_pause", + DryerMachineState.Pause: "pause", + DryerMachineState.RunningMainCycle: "running_maincycle", + DryerMachineState.RunningPostCycle: "running_postcycle", + DryerMachineState.Exceptions: "exception", + DryerMachineState.Complete: "complete", + DryerMachineState.PowerFailure: "power_failure", + DryerMachineState.ServiceDiagnostic: "service_diagnostic_mode", + DryerMachineState.FactoryDiagnostic: "factory_diagnostic_mode", + DryerMachineState.LifeTest: "life_test", + DryerMachineState.CustomerFocusMode: "customer_focus_mode", + DryerMachineState.DemoMode: "demo_mode", + DryerMachineState.HardStopOrError: "hard_stop_or_error", + DryerMachineState.SystemInit: "system_initialize", + DryerMachineState.Cancelled: "cancelled", } STATE_CYCLE_FILLING = "cycle_filling" @@ -61,32 +86,40 @@ STATE_CYCLE_SENSING = "cycle_sensing" STATE_CYCLE_SOAKING = "cycle_soaking" STATE_CYCLE_SPINNING = "cycle_spinning" STATE_CYCLE_WASHING = "cycle_washing" -STATE_DOOR_OPEN = "door_open" -def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: - """Determine correct states for a washer/dryer.""" +def washer_state(washer: Washer) -> str | None: + """Determine correct states for a washer.""" - if washer_dryer.get_door_open(): - return STATE_DOOR_OPEN + machine_state = washer.get_machine_state() - machine_state = washer_dryer.get_machine_state() - - if machine_state == MachineState.RunningMainCycle: - if washer_dryer.get_cycle_status_filling(): + if machine_state == WasherMachineState.RunningMainCycle: + if washer.get_cycle_status_filling(): return STATE_CYCLE_FILLING - if washer_dryer.get_cycle_status_rinsing(): + if washer.get_cycle_status_rinsing(): return STATE_CYCLE_RINSING - if washer_dryer.get_cycle_status_sensing(): + if washer.get_cycle_status_sensing(): return STATE_CYCLE_SENSING - if washer_dryer.get_cycle_status_soaking(): + if washer.get_cycle_status_soaking(): return STATE_CYCLE_SOAKING - if washer_dryer.get_cycle_status_spinning(): + if washer.get_cycle_status_spinning(): return STATE_CYCLE_SPINNING - if washer_dryer.get_cycle_status_washing(): + if washer.get_cycle_status_washing(): return STATE_CYCLE_WASHING - return WASHER_DRYER_MACHINE_STATE.get(machine_state) + return WASHER_MACHINE_STATE.get(machine_state) + + +def dryer_state(dryer: Dryer) -> str | None: + """Determine correct states for a dryer.""" + + machine_state = dryer.get_machine_state() + + if machine_state == DryerMachineState.RunningMainCycle: + if dryer.get_cycle_status_sensing(): + return STATE_CYCLE_SENSING + + return DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) @@ -96,15 +129,19 @@ class WhirlpoolSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Appliance], str | None] -WASHER_DRYER_STATE_OPTIONS = [ - *WASHER_DRYER_MACHINE_STATE.values(), +WASHER_STATE_OPTIONS = [ + *WASHER_MACHINE_STATE.values(), STATE_CYCLE_FILLING, STATE_CYCLE_RINSING, STATE_CYCLE_SENSING, STATE_CYCLE_SOAKING, STATE_CYCLE_SPINNING, STATE_CYCLE_WASHING, - STATE_DOOR_OPEN, +] + +DRYER_STATE_OPTIONS = [ + *DRYER_MACHINE_STATE.values(), + STATE_CYCLE_SENSING, ] WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( @@ -112,8 +149,8 @@ WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( key="state", translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=WASHER_DRYER_STATE_OPTIONS, - value_fn=washer_dryer_state, + options=WASHER_STATE_OPTIONS, + value_fn=washer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", @@ -130,8 +167,8 @@ DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( key="state", translation_key="dryer_state", device_class=SensorDeviceClass.ENUM, - options=WASHER_DRYER_STATE_OPTIONS, - value_fn=washer_dryer_state, + options=DRYER_STATE_OPTIONS, + value_fn=dryer_state, ), ) @@ -151,24 +188,40 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whirlpool sensors.""" - entities: list = [] appliances_manager = config_entry.runtime_data - for washer_dryer in appliances_manager.washer_dryers: - sensor_descriptions = ( - DRYER_SENSORS - if "dryer" in washer_dryer.appliance_info.data_model.lower() - else WASHER_SENSORS - ) - entities.extend( - WhirlpoolSensor(washer_dryer, description) - for description in sensor_descriptions - ) - entities.extend( - WasherDryerTimeSensor(washer_dryer, description) - for description in WASHER_DRYER_TIME_SENSORS - ) - async_add_entities(entities) + washer_sensors = [ + WhirlpoolSensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_SENSORS + ] + + washer_time_sensors = [ + WasherTimeSensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_DRYER_TIME_SENSORS + ] + + dryer_sensors = [ + WhirlpoolSensor(dryer, description) + for dryer in appliances_manager.dryers + for description in DRYER_SENSORS + ] + + dryer_time_sensors = [ + DryerTimeSensor(dryer, description) + for dryer in appliances_manager.dryers + for description in WASHER_DRYER_TIME_SENSORS + ] + + async_add_entities( + [ + *washer_sensors, + *washer_time_sensors, + *dryer_sensors, + *dryer_time_sensors, + ] + ) class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): @@ -187,22 +240,30 @@ class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): - """A timestamp class for the Whirlpool washer/dryer.""" +class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): + """Abstract base class for Whirlpool washer/dryer time sensors.""" _attr_should_poll = True + _appliance: Washer | Dryer def __init__( - self, washer_dryer: WasherDryer, description: SensorEntityDescription + self, appliance: Washer | Dryer, description: SensorEntityDescription ) -> None: - """Initialize the washer sensor.""" - super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + """Initialize the washer/dryer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description = description - self._wd = washer_dryer self._running: bool | None = None self._value: datetime | None = None + @abstractmethod + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + + @abstractmethod + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + async def async_added_to_hass(self) -> None: """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): @@ -212,28 +273,62 @@ class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): async def async_update(self) -> None: """Update status of Whirlpool.""" - await self._wd.fetch_data() + await self._appliance.fetch_data() @override @property def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" - machine_state = self._wd.get_machine_state() now = utcnow() - if ( - machine_state.value - in {MachineState.Complete.value, MachineState.Standby.value} - and self._running - ): + + if self._is_machine_state_finished() and self._running: self._running = False self._value = now - if machine_state is MachineState.RunningMainCycle: + if self._is_machine_state_running(): self._running = True - new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) + new_timestamp = now + timedelta( + seconds=self._appliance.get_time_remaining() + ) if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) ): self._value = new_timestamp return self._value + + +class WasherTimeSensor(WasherDryerTimeSensorBase): + """A timestamp class for Whirlpool washers.""" + + _appliance: Washer + + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + return self._appliance.get_machine_state() in { + WasherMachineState.Complete, + WasherMachineState.Standby, + } + + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + return ( + self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle + ) + + +class DryerTimeSensor(WasherDryerTimeSensorBase): + """A timestamp class for Whirlpool dryers.""" + + _appliance: Dryer + + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + return self._appliance.get_machine_state() in { + DryerMachineState.Complete, + DryerMachineState.Standby, + } + + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 2a22a2e8e4e..9f214bf204f 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -74,8 +74,7 @@ "cycle_sensing": "Cycle sensing", "cycle_soaking": "Cycle soaking", "cycle_spinning": "Cycle spinning", - "cycle_washing": "Cycle washing", - "door_open": "Door open" + "cycle_washing": "Cycle washing" } }, "dryer_state": { @@ -105,15 +104,14 @@ "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]", "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]", "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]", - "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]", - "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]" + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]" } }, "whirlpool_tank": { "name": "Detergent level", "state": { "unknown": "Unknown", - "empty": "Empty", + "empty": "[%key:common::state::empty%]", "25": "25%", "50": "50%", "100": "100%", diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index b236bb06208..814b952d417 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -52,7 +52,7 @@ "name": "Status", "state": { "add_period": "Add period", - "auto_renew_period": "Auto renew period", + "auto_renew_period": "Auto-renew period", "inactive": "Inactive", "ok": "Active", "active": "Active", diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 6cf216011f2..b6811190a27 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -29,8 +29,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up wiffi from a config entry, config_entry contains data from config entry database.""" - if not entry.update_listeners: - entry.add_update_listener(async_update_options) # create api object api = WiffiIntegrationApi(hass) @@ -53,11 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" api: WiffiIntegrationApi = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 308923597cd..c40bd5519e0 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PORT, CONF_TIMEOUT from homeassistant.core import callback @@ -76,7 +76,7 @@ class WiffiFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Wiffi server setup option flow.""" async def async_step_init( diff --git a/homeassistant/components/wilight/support.py b/homeassistant/components/wilight/support.py index 39578618d50..a88345bb1d6 100644 --- a/homeassistant/components/wilight/support.py +++ b/homeassistant/components/wilight/support.py @@ -4,7 +4,6 @@ from __future__ import annotations import calendar import locale -import re from typing import Any import voluptuous as vol @@ -26,7 +25,7 @@ def wilight_trigger(value: Any) -> str | None: if (step == 2) & isinstance(value, str): step = 3 err_desc = "String should only contain 8 decimals character" - if re.search(r"^([0-9]{8})$", value) is not None: + if len(value) == 8 and value.isdigit(): step = 4 err_desc = "First 3 character should be less than 128" result_128 = int(value[0:3]) < 128 diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index d8b59075368..dd154488be2 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -2,16 +2,23 @@ from __future__ import annotations +from dataclasses import asdict from typing import Any from yarl import URL +from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.webhook import async_generate_url as webhook_generate_url from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from . import CONF_CLOUDHOOK_URL, WithingsConfigEntry +TO_REDACT = { + "device_id", + "hashed_device_id", +} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: WithingsConfigEntry @@ -53,4 +60,8 @@ async def async_get_config_entry_diagnostics( "received_sleep_data": withings_data.sleep_coordinator.data is not None, "received_workout_data": withings_data.workout_coordinator.data is not None, "received_activity_data": withings_data.activity_coordinator.data is not None, + "devices": async_redact_data( + [asdict(v) for v in withings_data.device_coordinator.data.values()], + TO_REDACT, + ), } diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 0e986aaefa2..39be4d9a387 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.FAN, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, @@ -63,12 +64,12 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: WizConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool: """Set up the wiz integration from a config entry.""" ip_address = entry.data[CONF_HOST] _LOGGER.debug("Get bulb with IP: %s", ip_address) @@ -145,7 +146,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await entry.runtime_data.bulb.async_close() diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 92b25389450..a676c77688d 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -124,7 +124,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_HOST: device.ip_address}, ) - current_unique_ids = self._async_current_ids() + current_unique_ids = self._async_current_ids(include_ignore=False) current_hosts = { entry.data[CONF_HOST] for entry in self._async_current_entries(include_ignore=False) diff --git a/homeassistant/components/wiz/fan.py b/homeassistant/components/wiz/fan.py new file mode 100644 index 00000000000..f826ee80b8b --- /dev/null +++ b/homeassistant/components/wiz/fan.py @@ -0,0 +1,139 @@ +"""WiZ integration fan platform.""" + +from __future__ import annotations + +import math +from typing import Any, ClassVar + +from pywizlight.bulblibrary import BulbType, Features + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import WizConfigEntry +from .entity import WizEntity +from .models import WizData + +PRESET_MODE_BREEZE = "breeze" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WizConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the WiZ Platform from config_flow.""" + if entry.runtime_data.bulb.bulbtype.features.fan: + async_add_entities([WizFanEntity(entry.runtime_data, entry.title)]) + + +class WizFanEntity(WizEntity, FanEntity): + """Representation of WiZ Light bulb.""" + + _attr_name = None + + # We want the implementation of is_on to be the same as in ToggleEntity, + # but it is being overridden in FanEntity, so we need to restore it here. + is_on: ClassVar = ToggleEntity.is_on + + def __init__(self, wiz_data: WizData, name: str) -> None: + """Initialize a WiZ fan.""" + super().__init__(wiz_data, name) + bulb_type: BulbType = self._device.bulbtype + features: Features = bulb_type.features + + supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + if features.fan_reverse: + supported_features |= FanEntityFeature.DIRECTION + if features.fan_breeze_mode: + supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes = [PRESET_MODE_BREEZE] + + self._attr_supported_features = supported_features + self._attr_speed_count = bulb_type.fan_speed_range + + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + state = self._device.state + + self._attr_is_on = state.get_fan_state() > 0 + self._attr_percentage = ranged_value_to_percentage( + (1, self.speed_count), state.get_fan_speed() + ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + fan_mode = state.get_fan_mode() + self._attr_preset_mode = PRESET_MODE_BREEZE if fan_mode == 2 else None + if FanEntityFeature.DIRECTION in self.supported_features: + fan_reverse = state.get_fan_reverse() + self._attr_current_direction = None + if fan_reverse == 0: + self._attr_current_direction = DIRECTION_FORWARD + elif fan_reverse == 1: + self._attr_current_direction = DIRECTION_REVERSE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + # preset_mode == PRESET_MODE_BREEZE + await self._device.fan_set_state(mode=2) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: + await self.async_turn_off() + return + + speed = math.ceil(percentage_to_ranged_value((1, self.speed_count), percentage)) + await self._device.fan_set_state(mode=1, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + mode: int | None = None + speed: int | None = None + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + if preset_mode == PRESET_MODE_BREEZE: + mode = 2 + if percentage is not None: + speed = math.ceil( + percentage_to_ranged_value((1, self.speed_count), percentage) + ) + if mode is None: + mode = 1 + await self._device.fan_turn_on(mode=mode, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self._device.fan_turn_off(**kwargs) + await self.coordinator.async_request_refresh() + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + reverse = 1 if direction == DIRECTION_REVERSE else 0 + await self._device.fan_set_state(reverse=reverse) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 2ae78a8af92..57671ecd007 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -1,7 +1,7 @@ { "domain": "wiz", "name": "WiZ", - "codeowners": ["@sbidy"], + "codeowners": ["@sbidy", "@arturpragacz"], "config_flow": true, "dependencies": ["network"], "dhcp": [ diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index b4834347694..c3917507fb9 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -48,9 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when its updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True @@ -65,8 +62,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> boo coordinator.unsub() return unload_ok - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 2e0b7b1c793..e80760508a0 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -120,7 +120,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): return await wled.update() -class WLEDOptionsFlowHandler(OptionsFlow): +class WLEDOptionsFlowHandler(OptionsFlowWithReload): """Handle WLED options.""" async def async_step_init( diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 50dc0129369..1f15aea979b 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -28,7 +28,7 @@ "step": { "init": { "data": { - "keep_master_light": "Keep main light, even with 1 LED segment." + "keep_master_light": "Add 'Main' control even with single LED segment" } } } diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py index f1ab0489b86..1b2772a9c80 100644 --- a/homeassistant/components/wmspro/button.py +++ b/homeassistant/components/wmspro/button.py @@ -23,7 +23,7 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [ WebControlProIdentifyButton(config_entry.entry_id, dest) for dest in hub.dests.values() - if dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.Identify) ] async_add_entities(entities) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 0d9ccb8547d..e7255d478cb 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -2,13 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any from wmspro.const import ( WMS_WebControl_pro_API_actionDescription, WMS_WebControl_pro_API_actionType, + WMS_WebControl_pro_API_responseType, ) from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity @@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 @@ -33,9 +32,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.AwningDrive): entities.append(WebControlProAwning(config_entry.entry_id, dest)) - elif dest.action( + elif dest.hasAction( WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive ): entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) @@ -53,13 +52,14 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): def current_cover_position(self) -> int | None: """Return current position of cover.""" action = self._dest.action(self._drive_action_desc) + if action is None or action["percentage"] is None: + return None return 100 - action["percentage"] async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100 - kwargs[ATTR_POSITION]) - await asyncio.sleep(ACTION_DELAY) @property def is_closed(self) -> bool | None: @@ -70,13 +70,11 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Open the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=0) - await asyncio.sleep(ACTION_DELAY) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100) - await asyncio.sleep(ACTION_DELAY) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" @@ -84,8 +82,7 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): WMS_WebControl_pro_API_actionDescription.ManualCommand, WMS_WebControl_pro_API_actionType.Stop, ) - await action() - await asyncio.sleep(ACTION_DELAY) + await action(responseType=WMS_WebControl_pro_API_responseType.Detailed) class WebControlProAwning(WebControlProCover): diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index d828c8a26e8..2326734ceaf 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -2,11 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any -from wmspro.const import WMS_WebControl_pro_API_actionDescription +from wmspro.const import ( + WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_responseType, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant @@ -17,7 +19,6 @@ from . import WebControlProConfigEntry from .const import BRIGHTNESS_SCALE from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=15) PARALLEL_UPDATES = 1 @@ -32,9 +33,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.LightDimming): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightDimming): entities.append(WebControlProDimmer(config_entry.entry_id, dest)) - elif dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch): + elif dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightSwitch): entities.append(WebControlProLight(config_entry.entry_id, dest)) async_add_entities(entities) @@ -56,14 +57,16 @@ class WebControlProLight(WebControlProGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=True) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=True, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=False) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=False, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) class WebControlProDimmer(WebControlProLight): @@ -90,6 +93,6 @@ class WebControlProDimmer(WebControlProLight): WMS_WebControl_pro_API_actionDescription.LightDimming ) await action( - percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]) + percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]), + responseType=WMS_WebControl_pro_API_responseType.Detailed, ) - await asyncio.sleep(ACTION_DELAY) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index d4eda3a90a6..9dbcf09a7d4 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.2.2"] + "requirements": ["pywmspro==0.3.2"] } diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 60a0489ec5c..cbcf12cf31c 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -2,109 +2,72 @@ from __future__ import annotations -from functools import partial +from datetime import timedelta +from typing import cast -from holidays import HolidayBase, country_holidays +from holidays import DateLike, HolidayBase from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.util import dt as dt_util -from .const import CONF_PROVINCE, DOMAIN, PLATFORMS +from .const import ( + CONF_ADD_HOLIDAYS, + CONF_CATEGORY, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + LOGGER, + PLATFORMS, +) +from .util import ( + add_remove_custom_holidays, + async_validate_country_and_province, + get_holidays_object, + validate_dates, +) + +type WorkdayConfigEntry = ConfigEntry[HolidayBase] -async def _async_validate_country_and_province( - hass: HomeAssistant, entry: ConfigEntry, country: str | None, province: str | None -) -> None: - """Validate country and province.""" - - if not country: - return - try: - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # import executor job is used here because multiple integrations use - # the holidays library and it is not thread safe to import it in parallel - # https://github.com/python/cpython/issues/83065 - await hass.async_add_import_executor_job(country_holidays, country) - except NotImplementedError as ex: - async_create_issue( - hass, - DOMAIN, - "bad_country", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.ERROR, - translation_key="bad_country", - translation_placeholders={"title": entry.title}, - data={"entry_id": entry.entry_id, "country": None}, - ) - raise ConfigEntryError(f"Selected country {country} is not valid") from ex - - if not province: - return - try: - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # import executor job is used here because multiple integrations use - # the holidays library and it is not thread safe to import it in parallel - # https://github.com/python/cpython/issues/83065 - await hass.async_add_import_executor_job( - partial(country_holidays, country, subdiv=province) - ) - except NotImplementedError as ex: - async_create_issue( - hass, - DOMAIN, - "bad_province", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.ERROR, - translation_key="bad_province", - translation_placeholders={ - CONF_COUNTRY: country, - "title": entry.title, - }, - data={"entry_id": entry.entry_id, "country": country}, - ) - raise ConfigEntryError( - f"Selected province {province} for country {country} is not valid" - ) from ex - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> bool: """Set up Workday from a config entry.""" + calc_add_holidays = cast( + list[DateLike], validate_dates(entry.options[CONF_ADD_HOLIDAYS]) + ) + calc_remove_holidays: list[str] = validate_dates( + entry.options[CONF_REMOVE_HOLIDAYS] + ) + categories: list[str] | None = entry.options.get(CONF_CATEGORY) country: str | None = entry.options.get(CONF_COUNTRY) + days_offset: int = int(entry.options[CONF_OFFSET]) + language: str | None = entry.options.get(CONF_LANGUAGE) province: str | None = entry.options.get(CONF_PROVINCE) + year: int = (dt_util.now() + timedelta(days=days_offset)).year - await _async_validate_country_and_province(hass, entry, country, province) + await async_validate_country_and_province(hass, entry, country, province) - if country and CONF_LANGUAGE not in entry.options: - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # import executor job is used here because multiple integrations use - # the holidays library and it is not thread safe to import it in parallel - # https://github.com/python/cpython/issues/83065 - cls: HolidayBase = await hass.async_add_import_executor_job( - partial(country_holidays, country, subdiv=province) - ) - default_language = cls.default_language - new_options = entry.options.copy() - new_options[CONF_LANGUAGE] = default_language - hass.config_entries.async_update_entry(entry, options=new_options) + entry.runtime_data = await hass.async_add_executor_job( + get_holidays_object, country, province, year, language, categories + ) + + add_remove_custom_holidays( + hass, entry, country, calc_add_holidays, calc_remove_holidays + ) + + LOGGER.debug("Found the following holidays for your configuration:") + for holiday_date, name in sorted(entry.runtime_data.items()): + # Make explicit str variable to avoid "Incompatible types in assignment" + _holiday_string = holiday_date.strftime("%Y-%m-%d") + LOGGER.debug("%s %s", _holiday_string, name) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> bool: """Unload Workday config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index a48e19e59b2..69bdd315609 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,249 +2,40 @@ from __future__ import annotations -from datetime import date, datetime, timedelta +from datetime import datetime from typing import Final -from holidays import ( - PUBLIC, - HolidayBase, - __version__ as python_holidays_version, - country_holidays, -) +from holidays import HolidayBase import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME -from homeassistant.core import ( - CALLBACK_TYPE, - HomeAssistant, - ServiceResponse, - SupportsResponse, - callback, -) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, async_get_current_platform, ) -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.util import dt as dt_util, slugify -from .const import ( - ALLOWED_DAYS, - CONF_ADD_HOLIDAYS, - CONF_CATEGORY, - CONF_EXCLUDES, - CONF_OFFSET, - CONF_PROVINCE, - CONF_REMOVE_HOLIDAYS, - CONF_WORKDAYS, - DOMAIN, - LOGGER, -) +from . import WorkdayConfigEntry +from .const import CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS +from .entity import BaseWorkdayEntity SERVICE_CHECK_DATE: Final = "check_date" CHECK_DATE: Final = "check_date" -def validate_dates(holiday_list: list[str]) -> list[str]: - """Validate and adds to list of dates to add or remove.""" - calc_holidays: list[str] = [] - for add_date in holiday_list: - if add_date.find(",") > 0: - dates = add_date.split(",", maxsplit=1) - d1 = dt_util.parse_date(dates[0]) - d2 = dt_util.parse_date(dates[1]) - if d1 is None or d2 is None: - LOGGER.error("Incorrect dates in date range: %s", add_date) - continue - _range: timedelta = d2 - d1 - for i in range(_range.days + 1): - day: date = d1 + timedelta(days=i) - calc_holidays.append(day.strftime("%Y-%m-%d")) - continue - calc_holidays.append(add_date) - return calc_holidays - - -def _get_obj_holidays( - country: str | None, - province: str | None, - year: int, - language: str | None, - categories: list[str] | None, -) -> HolidayBase: - """Get the object for the requested country and year.""" - if not country: - return HolidayBase() - - set_categories = None - if categories: - category_list = [PUBLIC] - category_list.extend(categories) - set_categories = tuple(category_list) - - obj_holidays: HolidayBase = country_holidays( - country, - subdiv=province, - years=[year, year + 1], - language=language, - categories=set_categories, - ) - - supported_languages = obj_holidays.supported_languages - default_language = obj_holidays.default_language - - if default_language and not language: - # If no language is set, use the default language - LOGGER.debug("Changing language from None to %s", default_language) - return country_holidays( # Return default if no language - country, - subdiv=province, - years=year, - language=default_language, - categories=set_categories, - ) - - if ( - default_language - and language - and language not in supported_languages - and language.startswith("en") - ): - # If language does not match supported languages, use the first English variant - if default_language.startswith("en"): - LOGGER.debug("Changing language from %s to %s", language, default_language) - return country_holidays( # Return default English if default language - country, - subdiv=province, - years=year, - language=default_language, - categories=set_categories, - ) - for lang in supported_languages: - if lang.startswith("en"): - LOGGER.debug("Changing language from %s to %s", language, lang) - return country_holidays( - country, - subdiv=province, - years=year, - language=lang, - categories=set_categories, - ) - - if default_language and language and language not in supported_languages: - # If language does not match supported languages, use the default language - LOGGER.debug("Changing language from %s to %s", language, default_language) - return country_holidays( # Return default English if default language - country, - subdiv=province, - years=year, - language=default_language, - categories=set_categories, - ) - - return obj_holidays - - async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WorkdayConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Workday sensor.""" - add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS] - remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] - country: str | None = entry.options.get(CONF_COUNTRY) days_offset: int = int(entry.options[CONF_OFFSET]) excludes: list[str] = entry.options[CONF_EXCLUDES] - province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] - language: str | None = entry.options.get(CONF_LANGUAGE) - categories: list[str] | None = entry.options.get(CONF_CATEGORY) - - year: int = (dt_util.now() + timedelta(days=days_offset)).year - obj_holidays: HolidayBase = await hass.async_add_executor_job( - _get_obj_holidays, country, province, year, language, categories - ) - calc_add_holidays: list[str] = validate_dates(add_holidays) - calc_remove_holidays: list[str] = validate_dates(remove_holidays) - next_year = dt_util.now().year + 1 - - # Add custom holidays - try: - obj_holidays.append(calc_add_holidays) # type: ignore[arg-type] - except ValueError as error: - LOGGER.error("Could not add custom holidays: %s", error) - - # Remove holidays - for remove_holiday in calc_remove_holidays: - try: - # is this formatted as a date? - if dt_util.parse_date(remove_holiday): - # remove holiday by date - removed = obj_holidays.pop(remove_holiday) - LOGGER.debug("Removed %s", remove_holiday) - else: - # remove holiday by name - LOGGER.debug("Treating '%s' as named holiday", remove_holiday) - removed = obj_holidays.pop_named(remove_holiday) - for holiday in removed: - LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) - except KeyError as unmatched: - LOGGER.warning("No holiday found matching %s", unmatched) - if _date := dt_util.parse_date(remove_holiday): - if _date.year <= next_year: - # Only check and raise issues for current and next year - async_create_issue( - hass, - DOMAIN, - f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="bad_date_holiday", - translation_placeholders={ - CONF_COUNTRY: country if country else "-", - "title": entry.title, - CONF_REMOVE_HOLIDAYS: remove_holiday, - }, - data={ - "entry_id": entry.entry_id, - "country": country, - "named_holiday": remove_holiday, - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="bad_named_holiday", - translation_placeholders={ - CONF_COUNTRY: country if country else "-", - "title": entry.title, - CONF_REMOVE_HOLIDAYS: remove_holiday, - }, - data={ - "entry_id": entry.entry_id, - "country": country, - "named_holiday": remove_holiday, - }, - ) - - LOGGER.debug("Found the following holidays for your configuration:") - for holiday_date, name in sorted(obj_holidays.items()): - # Make explicit str variable to avoid "Incompatible types in assignment" - _holiday_string = holiday_date.strftime("%Y-%m-%d") - LOGGER.debug("%s %s", _holiday_string, name) + obj_holidays = entry.runtime_data platform = async_get_current_platform() platform.async_register_entity_service( @@ -269,14 +60,10 @@ async def async_setup_entry( ) -class IsWorkdaySensor(BinarySensorEntity): +class IsWorkdaySensor(BaseWorkdayEntity, BinarySensorEntity): """Implementation of a Workday sensor.""" - _attr_has_entity_name = True _attr_name = None - _attr_translation_key = DOMAIN - _attr_should_poll = False - unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -288,87 +75,20 @@ class IsWorkdaySensor(BinarySensorEntity): entry_id: str, ) -> None: """Initialize the Workday sensor.""" - self._obj_holidays = obj_holidays - self._workdays = workdays - self._excludes = excludes - self._days_offset = days_offset + super().__init__( + obj_holidays, + workdays, + excludes, + days_offset, + name, + entry_id, + ) self._attr_extra_state_attributes = { CONF_WORKDAYS: workdays, CONF_EXCLUDES: excludes, CONF_OFFSET: days_offset, } - self._attr_unique_id = entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="python-holidays", - model=python_holidays_version, - name=name, - ) - - def is_include(self, day: str, now: date) -> bool: - """Check if given day is in the includes list.""" - if day in self._workdays: - return True - if "holiday" in self._workdays and now in self._obj_holidays: - return True - - return False - - def is_exclude(self, day: str, now: date) -> bool: - """Check if given day is in the excludes list.""" - if day in self._excludes: - return True - if "holiday" in self._excludes and now in self._obj_holidays: - return True - - return False - - def get_next_interval(self, now: datetime) -> datetime: - """Compute next time an update should occur.""" - tomorrow = dt_util.as_local(now) + timedelta(days=1) - return dt_util.start_of_local_day(tomorrow) - - def _update_state_and_setup_listener(self) -> None: - """Update state and setup listener for next interval.""" - now = dt_util.now() - self.update_data(now) - self.unsub = async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, self.get_next_interval(now) - ) - - @callback - def point_in_time_listener(self, time_date: datetime) -> None: - """Get the latest data and update state.""" - self._update_state_and_setup_listener() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Set up first update.""" - self._update_state_and_setup_listener() def update_data(self, now: datetime) -> None: """Get date and look whether it is a holiday.""" self._attr_is_on = self.date_is_workday(now) - - def check_date(self, check_date: date) -> ServiceResponse: - """Service to check if date is workday or not.""" - return {"workday": self.date_is_workday(check_date)} - - def date_is_workday(self, check_date: date) -> bool: - """Check if date is workday.""" - # Default is no workday - is_workday = False - - # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = check_date + timedelta(days=self._days_offset) - day = adjusted_date.isoweekday() - 1 - day_of_week = ALLOWED_DAYS[day] - - if self.is_include(day_of_week, adjusted_date): - is_workday = True - - if self.is_exclude(day_of_week, adjusted_date): - is_workday = False - - return is_workday diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 7a8a8181a9f..20d9040e527 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback @@ -86,6 +86,9 @@ def add_province_and_language_to_schema( SelectOptionDict(value=k, label=", ".join(v)) for k, v in subdiv_aliases.items() ] + for option in province_options: + if option["label"] == "": + option["label"] = option["value"] else: province_options = provinces province_schema = { @@ -311,7 +314,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class WorkdayOptionsFlowHandler(OptionsFlow): +class WorkdayOptionsFlowHandler(OptionsFlowWithReload): """Handle Workday options.""" async def async_step_init( diff --git a/homeassistant/components/workday/entity.py b/homeassistant/components/workday/entity.py new file mode 100644 index 00000000000..c75a4089ed2 --- /dev/null +++ b/homeassistant/components/workday/entity.py @@ -0,0 +1,115 @@ +"""Base workday entity.""" + +from __future__ import annotations + +from abc import abstractmethod +from datetime import date, datetime, timedelta + +from holidays import HolidayBase, __version__ as python_holidays_version + +from homeassistant.core import CALLBACK_TYPE, ServiceResponse, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +from .const import ALLOWED_DAYS, DOMAIN + + +class BaseWorkdayEntity(Entity): + """Implementation of a base Workday entity.""" + + _attr_has_entity_name = True + _attr_translation_key = DOMAIN + _attr_should_poll = False + unsub: CALLBACK_TYPE | None = None + + def __init__( + self, + obj_holidays: HolidayBase, + workdays: list[str], + excludes: list[str], + days_offset: int, + name: str, + entry_id: str, + ) -> None: + """Initialize the Workday entity.""" + self._obj_holidays = obj_holidays + self._workdays = workdays + self._excludes = excludes + self._days_offset = days_offset + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="python-holidays", + model=python_holidays_version, + name=name, + ) + + def is_include(self, day: str, now: date) -> bool: + """Check if given day is in the includes list.""" + if day in self._workdays: + return True + if "holiday" in self._workdays and now in self._obj_holidays: + return True + + return False + + def is_exclude(self, day: str, now: date) -> bool: + """Check if given day is in the excludes list.""" + if day in self._excludes: + return True + if "holiday" in self._excludes and now in self._obj_holidays: + return True + + return False + + def get_next_interval(self, now: datetime) -> datetime: + """Compute next time an update should occur.""" + tomorrow = dt_util.as_local(now) + timedelta(days=1) + return dt_util.start_of_local_day(tomorrow) + + def _update_state_and_setup_listener(self) -> None: + """Update state and setup listener for next interval.""" + now = dt_util.now() + self.update_data(now) + self.unsub = async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval(now) + ) + + @callback + def point_in_time_listener(self, time_date: datetime) -> None: + """Get the latest data and update state.""" + self._update_state_and_setup_listener() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Set up first update.""" + self._update_state_and_setup_listener() + + @abstractmethod + def update_data(self, now: datetime) -> None: + """Update data.""" + + def check_date(self, check_date: date) -> ServiceResponse: + """Service to check if date is workday or not.""" + return {"workday": self.date_is_workday(check_date)} + + def date_is_workday(self, check_date: date) -> bool: + """Check if date is workday.""" + # Default is no workday + is_workday = False + + # Get ISO day of the week (1 = Monday, 7 = Sunday) + adjusted_date = check_date + timedelta(days=self._days_offset) + day = adjusted_date.isoweekday() - 1 + day_of_week = ALLOWED_DAYS[day] + + if self.is_include(day_of_week, adjusted_date): + is_workday = True + + if self.is_exclude(day_of_week, adjusted_date): + is_workday = False + + return is_workday diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index f9fae38f1f5..0e336632b2e 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.75"] + "requirements": ["holidays==0.79"] } diff --git a/homeassistant/components/workday/util.py b/homeassistant/components/workday/util.py new file mode 100644 index 00000000000..726563febaf --- /dev/null +++ b/homeassistant/components/workday/util.py @@ -0,0 +1,254 @@ +"""Helpers functions for the Workday component.""" + +from datetime import date, timedelta +from functools import partial +from typing import TYPE_CHECKING + +from holidays import PUBLIC, DateLike, HolidayBase, country_holidays + +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.util import dt as dt_util, slugify + +if TYPE_CHECKING: + from . import WorkdayConfigEntry +from .const import CONF_REMOVE_HOLIDAYS, DOMAIN, LOGGER + + +async def async_validate_country_and_province( + hass: HomeAssistant, + entry: "WorkdayConfigEntry", + country: str | None, + province: str | None, +) -> None: + """Validate country and province.""" + + if not country: + return + try: + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + await hass.async_add_import_executor_job(country_holidays, country) + except NotImplementedError as ex: + async_create_issue( + hass, + DOMAIN, + "bad_country", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.ERROR, + translation_key="bad_country", + translation_placeholders={"title": entry.title}, + data={"entry_id": entry.entry_id, "country": None}, + ) + raise ConfigEntryError(f"Selected country {country} is not valid") from ex + + if not province: + return + try: + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + await hass.async_add_import_executor_job( + partial(country_holidays, country, subdiv=province) + ) + except NotImplementedError as ex: + async_create_issue( + hass, + DOMAIN, + "bad_province", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.ERROR, + translation_key="bad_province", + translation_placeholders={ + CONF_COUNTRY: country, + "title": entry.title, + }, + data={"entry_id": entry.entry_id, "country": country}, + ) + raise ConfigEntryError( + f"Selected province {province} for country {country} is not valid" + ) from ex + + +def validate_dates(holiday_list: list[str]) -> list[str]: + """Validate and add to list of dates to add or remove.""" + calc_holidays: list[str] = [] + for add_date in holiday_list: + if add_date.find(",") > 0: + dates = add_date.split(",", maxsplit=1) + d1 = dt_util.parse_date(dates[0]) + d2 = dt_util.parse_date(dates[1]) + if d1 is None or d2 is None: + LOGGER.error("Incorrect dates in date range: %s", add_date) + continue + _range: timedelta = d2 - d1 + for i in range(_range.days + 1): + day: date = d1 + timedelta(days=i) + calc_holidays.append(day.strftime("%Y-%m-%d")) + continue + calc_holidays.append(add_date) + return calc_holidays + + +def get_holidays_object( + country: str | None, + province: str | None, + year: int, + language: str | None, + categories: list[str] | None, +) -> HolidayBase: + """Get the object for the requested country and year.""" + if not country: + return HolidayBase() + + set_categories = None + if categories: + category_list = [PUBLIC] + category_list.extend(categories) + set_categories = tuple(category_list) + + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=[year, year + 1], + language=language, + categories=set_categories, + ) + + supported_languages = obj_holidays.supported_languages + default_language = obj_holidays.default_language + + if default_language and not language: + # If no language is set, use the default language + LOGGER.debug("Changing language from None to %s", default_language) + return country_holidays( # Return default if no language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + + if ( + default_language + and language + and language not in supported_languages + and language.startswith("en") + ): + # If language does not match supported languages, use the first English variant + if default_language.startswith("en"): + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + for lang in supported_languages: + if lang.startswith("en"): + LOGGER.debug("Changing language from %s to %s", language, lang) + return country_holidays( + country, + subdiv=province, + years=year, + language=lang, + categories=set_categories, + ) + + if default_language and language and language not in supported_languages: + # If language does not match supported languages, use the default language + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + + return obj_holidays + + +def add_remove_custom_holidays( + hass: HomeAssistant, + entry: "WorkdayConfigEntry", + country: str | None, + calc_add_holidays: list[DateLike], + calc_remove_holidays: list[str], +) -> None: + """Add or remove custom holidays.""" + next_year = dt_util.now().year + 1 + + # Add custom holidays + try: + entry.runtime_data.append(calc_add_holidays) + except ValueError as error: + LOGGER.error("Could not add custom holidays: %s", error) + + # Remove custom holidays + for remove_holiday in calc_remove_holidays: + try: + # is this formatted as a date? + if dt_util.parse_date(remove_holiday): + # remove holiday by date + removed = entry.runtime_data.pop(remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) + else: + # remove holiday by name + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + removed = entry.runtime_data.pop_named(remove_holiday) + for holiday in removed: + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) + except KeyError as unmatched: + LOGGER.warning("No holiday found matching %s", unmatched) + if _date := dt_util.parse_date(remove_holiday): + if _date.year <= next_year: + # Only check and raise issues for max next year + async_create_issue( + hass, + DOMAIN, + f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_date_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_named_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 32c6a11f25c..23a27adeb69 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -100,7 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Close the WS66i connection to the amplifier.""" ws66i.close() - entry.async_on_unload(entry.add_update_listener(_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) ) @@ -119,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index 120b7738d2e..e70dbd4e8d7 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant, callback @@ -142,7 +142,7 @@ def _key_for_source( ) -class Ws66iOptionsFlowHandler(OptionsFlow): +class Ws66iOptionsFlowHandler(OptionsFlowWithReload): """Handle a WS66i options flow.""" async def async_step_init( diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 1a1a67bf1de..03470dbe555 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -132,6 +132,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): # Used to ensure TTS timeout is acted on correctly. self._run_loop_id: str | None = None + # TTS streaming + self._tts_stream_token: str | None = None + self._is_tts_streaming: bool = False + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -179,11 +183,20 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): """Set state based on pipeline stage.""" assert self._client is not None - if event.type == assist_pipeline.PipelineEventType.RUN_END: + if event.type == assist_pipeline.PipelineEventType.RUN_START: + if event.data and (tts_output := event.data["tts_output"]): + # Get stream token early. + # If "tts_start_streaming" is True in INTENT_PROGRESS event, we + # can start streaming TTS before the TTS_END event. + self._tts_stream_token = tts_output["token"] + self._is_tts_streaming = False + elif event.type == assist_pipeline.PipelineEventType.RUN_END: # Pipeline run is complete self._is_pipeline_running = False self._pipeline_ended_event.set() self.device.set_is_active(False) + self._tts_stream_token = None + self._is_tts_streaming = False elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: self.config_entry.async_create_background_task( self.hass, @@ -245,6 +258,20 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._client.write_event(Transcript(text=stt_text).event()), f"{self.entity_id} {event.type}", ) + elif event.type == assist_pipeline.PipelineEventType.INTENT_PROGRESS: + if ( + event.data + and event.data.get("tts_start_streaming") + and self._tts_stream_token + and (stream := tts.async_get_stream(self.hass, self._tts_stream_token)) + ): + # Start streaming TTS early (before TTS_END). + self._is_tts_streaming = True + self.config_entry.async_create_background_task( + self.hass, + self._stream_tts(stream), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.TTS_START: # Text-to-speech text if event.data: @@ -267,8 +294,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): if ( event.data and (tts_output := event.data["tts_output"]) + and not self._is_tts_streaming and (stream := tts.async_get_stream(self.hass, tts_output["token"])) ): + # Send TTS only if we haven't already started streaming it in INTENT_PROGRESS. self.config_entry.async_create_background_task( self.hass, self._stream_tts(stream), @@ -711,39 +740,62 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): start_time = time.monotonic() try: - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + header_data = b"" + header_complete = False + sample_rate: int | None = None + sample_width: int | None = None + sample_channels: int | None = None + timestamp = 0 - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + async for data_chunk in tts_result.async_stream_result(): + if not header_complete: + # Accumulate data until we can parse the header and get + # sample rate, etc. + header_data += data_chunk + # Most WAVE headers are 44 bytes in length + if (len(header_data) >= 44) and ( + audio_info := _try_parse_wav_header(header_data) + ): + # Overwrite chunk with audio after header + sample_rate, sample_width, sample_channels, data_chunk = ( + audio_info + ) + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + header_complete = True - timestamp = 0 - await self._client.write_event( - AudioStart( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - timestamp=timestamp, - ).event() + if not data_chunk: + # No audio after header + continue + else: + # Header is incomplete + continue + + # Streaming audio + assert sample_rate is not None + assert sample_width is not None + assert sample_channels is not None + + audio_chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=data_chunk, + timestamp=timestamp, ) - # Stream audio chunks - while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): - chunk = AudioChunk( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - audio=audio_bytes, - timestamp=timestamp, - ) - await self._client.write_event(chunk.event()) - timestamp += chunk.milliseconds - total_seconds += chunk.seconds + await self._client.write_event(audio_chunk.event()) + timestamp += audio_chunk.milliseconds + total_seconds += audio_chunk.seconds - await self._client.write_event(AudioStop(timestamp=timestamp).event()) - _LOGGER.debug("TTS streaming complete") + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") finally: send_duration = time.monotonic() - start_time timeout_seconds = max(0, total_seconds - send_duration + _TTS_TIMEOUT_EXTRA) @@ -812,3 +864,25 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self.config_entry.async_create_background_task( self.hass, self._client.write_event(event), "wyoming timer event" ) + + +def _try_parse_wav_header(header_data: bytes) -> tuple[int, int, int, bytes] | None: + """Try to parse a WAV header from a buffer. + + If successful, return (rate, width, channels, audio). + """ + try: + with io.BytesIO(header_data) as wav_io: + wav_file: wave.Wave_read = wave.open(wav_io, "rb") + with wav_file: + return ( + wav_file.getframerate(), + wav_file.getsampwidth(), + wav_file.getnchannels(), + wav_file.readframes(wav_file.getnframes()), + ) + except wave.Error: + # Ignore errors and return None + pass + + return None diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 31adb17d7f5..39f5267006e 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -13,6 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.7.1"], + "requirements": ["wyoming==1.7.2"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 79e431fee98..cf088c04d9f 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -1,13 +1,21 @@ """Support for Wyoming text-to-speech services.""" from collections import defaultdict +from collections.abc import AsyncGenerator import io import logging import wave -from wyoming.audio import AudioChunk, AudioStop +from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient -from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.tts import ( + Synthesize, + SynthesizeChunk, + SynthesizeStart, + SynthesizeStop, + SynthesizeStopped, + SynthesizeVoice, +) from homeassistant.components import tts from homeassistant.config_entries import ConfigEntry @@ -45,6 +53,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): service: WyomingService, ) -> None: """Set up provider.""" + self.config_entry = config_entry self.service = service self._tts_service = next(tts for tts in service.info.tts if tts.installed) @@ -150,3 +159,98 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): return (None, None) return ("wav", data) + + def async_supports_streaming_input(self) -> bool: + """Return if the TTS engine supports streaming input.""" + return self._tts_service.supports_synthesize_streaming + + async def async_stream_tts_audio( + self, request: tts.TTSAudioRequest + ) -> tts.TTSAudioResponse: + """Generate speech from an incoming message.""" + voice_name: str | None = request.options.get(tts.ATTR_VOICE) + voice_speaker: str | None = request.options.get(ATTR_SPEAKER) + voice: SynthesizeVoice | None = None + if voice_name is not None: + voice = SynthesizeVoice(name=voice_name, speaker=voice_speaker) + + client = AsyncTcpClient(self.service.host, self.service.port) + await client.connect() + + # Stream text chunks to client + self.config_entry.async_create_background_task( + self.hass, + self._write_tts_message(request.message_gen, client, voice), + "wyoming tts write", + ) + + async def data_gen(): + # Stream audio bytes from client + try: + async for data_chunk in self._read_tts_audio(client): + yield data_chunk + finally: + await client.disconnect() + + return tts.TTSAudioResponse("wav", data_gen()) + + async def _write_tts_message( + self, + message_gen: AsyncGenerator[str], + client: AsyncTcpClient, + voice: SynthesizeVoice | None, + ) -> None: + """Write text chunks to the client.""" + try: + # Start stream + await client.write_event(SynthesizeStart(voice=voice).event()) + + # Accumulate entire message for synthesize event. + message = "" + async for message_chunk in message_gen: + message += message_chunk + + await client.write_event(SynthesizeChunk(text=message_chunk).event()) + + # Send entire message for backwards compatibility + await client.write_event(Synthesize(text=message, voice=voice).event()) + + # End stream + await client.write_event(SynthesizeStop().event()) + except (OSError, WyomingError): + # Disconnected + _LOGGER.warning("Unexpected disconnection from TTS client") + + async def _read_tts_audio(self, client: AsyncTcpClient) -> AsyncGenerator[bytes]: + """Read audio events from the client and yield WAV audio chunks. + + The WAV header is sent first with a frame count of 0 to indicate that + we're streaming and don't know the number of frames ahead of time. + """ + wav_header_sent = False + + try: + while event := await client.read_event(): + if wav_header_sent and AudioChunk.is_type(event.type): + # PCM audio + yield AudioChunk.from_event(event).audio + elif (not wav_header_sent) and AudioStart.is_type(event.type): + # WAV header with nframes = 0 for streaming + audio_start = AudioStart.from_event(event) + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(audio_start.rate) + wav_file.setsampwidth(audio_start.width) + wav_file.setnchannels(audio_start.channels) + + wav_io.seek(0) + yield wav_io.getvalue() + + wav_header_sent = True + elif SynthesizeStopped.is_type(event.type): + # All TTS audio has been received + break + except (OSError, WyomingError): + # Disconnected + _LOGGER.warning("Unexpected disconnection from TTS client") diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index 69fc427013a..a07b7fde3b1 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -67,7 +67,7 @@ class XiaomiActiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class XiaomiPassiveBluetoothDataProcessor[_T]( diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 2b87da630a0..bd318c5e30b 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.39.0"] + "requirements": ["xiaomi-ble==1.2.0"] } diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 06b49b8e86f..5f64cd1acdc 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -59,13 +59,13 @@ "device_automation": { "trigger_subtype": { "press": "Press", - "double_press": "Double Press", - "long_press": "Long Press", - "motion_detected": "Motion Detected", - "rotate_left": "Rotate Left", - "rotate_right": "Rotate Right", - "rotate_left_pressed": "Rotate Left (Pressed)", - "rotate_right_pressed": "Rotate Right (Pressed)", + "double_press": "Double press", + "long_press": "Long press", + "motion_detected": "Motion detected", + "rotate_left": "Rotate left", + "rotate_right": "Rotate right", + "rotate_left_pressed": "Rotate left (pressed)", + "rotate_right_pressed": "Rotate right (pressed)", "match_successful": "Match successful", "match_failed": "Match failed", "low_quality_too_light_fuzzy": "Low quality (too light, fuzzy)", @@ -75,7 +75,7 @@ "lock_outside_the_door": "Lock outside the door", "unlock_outside_the_door": "Unlock outside the door", "lock_inside_the_door": "Lock inside the door", - "unlock_inside_the_door": "Unlock outside the door", + "unlock_inside_the_door": "Unlock inside the door", "locked": "Locked", "turn_on_antilock": "Turn on antilock", "release_the_antilock": "Release antilock", @@ -224,7 +224,7 @@ "state_attributes": { "event_type": { "state": { - "motion_detected": "Motion Detected" + "motion_detected": "Motion detected" } } } @@ -235,7 +235,7 @@ "name": "Impedance" }, "weight_non_stabilized": { - "name": "Weight non stabilized" + "name": "Weight non-stabilized" } } } diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0e28a2900bb..8db5273174b 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -466,8 +466,6 @@ async def async_setup_gateway_entry( await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_setup_device_entry( hass: HomeAssistant, entry: XiaomiMiioConfigEntry @@ -481,8 +479,6 @@ async def async_setup_device_entry( await hass.config_entries.async_forward_entry_setups(entry, platforms) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -493,10 +489,3 @@ async def async_unload_entry( platforms = get_platforms(config_entry) return await hass.config_entries.async_unload_platforms(config_entry, platforms) - - -async def update_listener( - hass: HomeAssistant, config_entry: XiaomiMiioConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b8d8b028006..95eabb0188c 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,11 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -56,7 +60,7 @@ DEVICE_CLOUD_CONFIG = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index fef185daf41..00e11224649 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -311,7 +311,7 @@ "name": "Learn mode" }, "auto_detect": { - "name": "Auto detect" + "name": "Autodetect" }, "ionizer": { "name": "Ionizer" diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index d77d70ff86c..d128e3e5111 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], "quality_scale": "legacy", - "requirements": ["slixmpp==1.8.5", "emoji==2.8.0"] + "requirements": ["slixmpp==1.10.0", "emoji==2.8.0"] } diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 968f925d1e8..c9829746d59 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -144,7 +144,8 @@ async def async_send_message( # noqa: C901 self.loop = hass.loop - self.force_starttls = use_tls + self.enable_starttls = use_tls + self.enable_direct_tls = use_tls self.use_ipv6 = False self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail) self.add_event_handler("session_start", self.start) @@ -163,7 +164,7 @@ async def async_send_message( # noqa: C901 self.register_plugin("xep_0128") # Service Discovery self.register_plugin("xep_0363") # HTTP upload - self.connect(force_starttls=self.force_starttls, use_ssl=False) + self.connect() async def start(self, event): """Start the communication and sends the message.""" diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 4d9ea9ec2c9..9086bb15575 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] + "requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index d67e136be4a..5c481719cc9 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -22,16 +22,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: YaleConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 1aaad2aa63a..d8c1fc80f8f 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -171,7 +171,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): ) -class YaleOptionsFlowHandler(OptionsFlow): +class YaleOptionsFlowHandler(OptionsFlowWithReload): """Handle Yale options.""" async def async_step_init( diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index c5183623660..68d64494e41 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -32,7 +32,11 @@ from .util import async_find_existing_service_info, bluetooth_callback_matcher type YALEXSBLEConfigEntry = ConfigEntry[YaleXSBLEData] -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> bool: diff --git a/homeassistant/components/yalexs_ble/icons.json b/homeassistant/components/yalexs_ble/icons.json new file mode 100644 index 00000000000..0b4929cd778 --- /dev/null +++ b/homeassistant/components/yalexs_ble/icons.json @@ -0,0 +1,11 @@ +{ + "entity": { + "lock": { + "secure_mode": { + "state": { + "locked": "mdi:shield-lock" + } + } + } + } +} diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 78b92ab9eb1..3d822714fb5 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity +from .models import YaleXSBLEData async def async_setup_entry( @@ -20,13 +21,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up locks.""" - async_add_entities([YaleXSBLELock(entry.runtime_data)]) + async_add_entities( + [YaleXSBLELock(entry.runtime_data), YaleXSBLESecureModeLock(entry.runtime_data)] + ) -class YaleXSBLELock(YALEXSBLEEntity, LockEntity): +class YaleXSBLEBaseLock(YALEXSBLEEntity, LockEntity): """A yale xs ble lock.""" - _attr_name = None + _secure_mode: bool = False @callback def _async_update_state( @@ -39,11 +42,13 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): self._attr_is_jammed = False lock_state = new_state.lock if lock_state is LockStatus.LOCKED: - self._attr_is_locked = True + self._attr_is_locked = not self._secure_mode elif lock_state is LockStatus.LOCKING: self._attr_is_locking = True elif lock_state is LockStatus.UNLOCKING: self._attr_is_unlocking = True + elif lock_state is LockStatus.SECUREMODE: + self._attr_is_locked = True elif lock_state in ( LockStatus.UNKNOWN_01, LockStatus.UNKNOWN_06, @@ -57,6 +62,29 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): """Unlock the lock.""" await self._device.unlock() + +class YaleXSBLELock(YaleXSBLEBaseLock, LockEntity): + """A yale xs ble lock not in secure mode.""" + + _attr_name = None + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self._device.lock() + + +class YaleXSBLESecureModeLock(YaleXSBLEBaseLock): + """A yale xs ble lock in secure mode.""" + + _attr_entity_registry_enabled_default = False + _attr_translation_key = "secure_mode" + _secure_mode = True + + def __init__(self, data: YaleXSBLEData) -> None: + """Initialize the entity.""" + super().__init__(data) + self._attr_unique_id = f"{self._device.address}_secure_mode" + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + await self._device.securemode() diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 2387f5dc15f..b1fad926f1d 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.6.0"] + "requirements": ["yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index c79830be3a9..92d807d01f6 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -51,6 +51,11 @@ "battery_voltage": { "name": "Battery voltage" } + }, + "lock": { + "secure_mode": { + "name": "Secure mode" + } } } } diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 3e890c8b943..edc124890c5 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging +from aiohttp import DummyCookieJar from aiomusiccast.musiccast_device import MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -52,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = MusicCastDevice( entry.data[CONF_HOST], - async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=DummyCookieJar()), entry.data[CONF_UPNP_DESC], ) coordinator = MusicCastDataUpdateCoordinator(hass, entry, client=client) diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index c43e547a71e..b48b5f6e67b 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -6,13 +6,13 @@ import logging from typing import Any from urllib.parse import urlparse -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, DummyCookieJar from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -50,7 +50,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): try: info = await MusicCastDevice.get_device_info( - host, async_get_clientsession(self.hass) + host, async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()) ) except (MusicCastConnectionException, ClientConnectorError): errors["base"] = "cannot_connect" @@ -89,7 +89,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( - discovery_info.ssdp_location, async_get_clientsession(self.hass) + discovery_info.ssdp_location, + async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()), ): return self.async_abort(reason="yxc_control_url_missing") diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 0b3ceaf2aee..cb24edae1fd 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -232,9 +232,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Wait to install the reload listener until everything was successfully initialized - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -245,11 +242,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def _async_get_device( hass: HomeAssistant, host: str, entry: ConfigEntry ) -> YeelightDevice: diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 15975ba22bd..cc3ab35f684 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback @@ -298,7 +298,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return MODEL_UNKNOWN -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Yeelight.""" async def async_step_init( diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 07970cb25ca..d65ebb3a25a 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.45.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7132fd6a414..f33da34c1fc 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -43,6 +43,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, @@ -165,6 +166,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = YoLinkHomeStore( yolink_home, device_coordinators ) + + # Clean up yolink devices which are not associated to the account anymore. + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + for identifier in device_entry.identifiers: + if ( + identifier[0] == DOMAIN + and device_coordinators.get(identifier[1]) is None + ): + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def async_yolink_unload(event) -> None: diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 7f965650354..d57e942734e 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -12,6 +12,7 @@ from yolink.const import ( ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SMOKE_ALARM, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ) @@ -53,6 +54,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SMOKE_ALARM, ] @@ -90,8 +92,10 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="smoke_detected", device_class=BinarySensorDeviceClass.SMOKE, - value=lambda state: state.get("smokeAlarm"), - exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, + value=lambda state: state.get("smokeAlarm") is True + or state.get("denseSmokeAlarm") is True, + exists_fn=lambda device: device.device_type + in [ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_SMOKE_ALARM], ), YoLinkBinarySensorEntityDescription( key="pipe_leak_detected", diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 9556c1bbd82..851b65e1a15 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -32,6 +32,8 @@ DEV_MODEL_FLEX_FOB_YS3614_UC = "YS3614-UC" DEV_MODEL_FLEX_FOB_YS3614_EC = "YS3614-EC" DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC" DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC" +DEV_MODEL_PLUG_YS6614_UC = "YS6614-UC" +DEV_MODEL_PLUG_YS6614_EC = "YS6614-EC" DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC" DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC" DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index 7d5323663de..2c914e84a08 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -5,13 +5,16 @@ from __future__ import annotations import asyncio from datetime import UTC, datetime, timedelta import logging +from typing import Any +from yolink.client_request import ClientRequest from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from yolink.model import BRDP from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME @@ -89,3 +92,16 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): self.dev_net_type = dev_lora_info.get("devNetType") return device_state return {} + + async def call_device(self, request: ClientRequest) -> dict[str, Any]: + """Call device api.""" + try: + # call_device will check result, fail by raise YoLinkClientError + resp: BRDP = await self.device.call_device(request) + except YoLinkAuthFailError as yl_auth_err: + self.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError(yl_auth_err) from yl_auth_err + except YoLinkClientError as yl_client_err: + raise HomeAssistantError(yl_client_err) from yl_client_err + else: + return resp.data diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 7828bf91541..ecc42ad1a0e 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -3,13 +3,12 @@ from __future__ import annotations from abc import abstractmethod +from typing import Any from yolink.client_request import ClientRequest -from yolink.exception import YoLinkAuthFailError, YoLinkClientError from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -64,13 +63,6 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def update_entity_state(self, state: dict) -> None: """Parse and update entity state, should be overridden.""" - async def call_device(self, request: ClientRequest) -> None: + async def call_device(self, request: ClientRequest) -> dict[str, Any]: """Call device api.""" - try: - # call_device will check result, fail by raise YoLinkClientError - await self.coordinator.device.call_device(request) - except YoLinkAuthFailError as yl_auth_err: - self.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(yl_auth_err) from yl_auth_err - except YoLinkClientError as yl_client_err: - raise HomeAssistantError(yl_client_err) from yl_client_err + return await self.coordinator.call_device(request) diff --git a/homeassistant/components/yolink/icons.json b/homeassistant/components/yolink/icons.json index 6d9062a92b8..59366b804f5 100644 --- a/homeassistant/components/yolink/icons.json +++ b/homeassistant/components/yolink/icons.json @@ -27,10 +27,20 @@ "default": "mdi:gauge" } }, + "select": { + "sprinkler_mode": { + "default": "mdi:auto-mode" + } + }, "switch": { "manipulator_state": { "default": "mdi:pipe" } + }, + "valve": { + "sprinkler_valve": { + "default": "mdi:sprinkler-variant" + } } }, "services": { diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 779b830637b..138667e7e73 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.5.5"] + "requirements": ["yolink-api==0.5.8"] } diff --git a/homeassistant/components/yolink/select.py b/homeassistant/components/yolink/select.py new file mode 100644 index 00000000000..e98b0440b92 --- /dev/null +++ b/homeassistant/components/yolink/select.py @@ -0,0 +1,119 @@ +"""YoLink select platform.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from yolink.client_request import ClientRequest +from yolink.const import ATTR_DEVICE_SPRINKLER +from yolink.device import YoLinkDevice +from yolink.message_resolver import sprinkler_message_resolve + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import YoLinkCoordinator +from .entity import YoLinkEntity + + +@dataclass(frozen=True, kw_only=True) +class YoLinkSelectEntityDescription(SelectEntityDescription): + """YoLink SelectEntityDescription.""" + + state_key: str = "state" + exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True + should_update_entity: Callable = lambda state: True + value: Callable = lambda data: data + on_option_selected: Callable[[YoLinkCoordinator, str], Awaitable[bool]] + + +async def set_sprinker_mode_fn(coordinator: YoLinkCoordinator, option: str) -> bool: + """Set sprinkler mode.""" + data: dict[str, Any] = await coordinator.call_device( + ClientRequest( + "setState", + { + "state": { + "mode": option, + } + }, + ) + ) + sprinkler_message_resolve(coordinator.device, data, None) + coordinator.async_set_updated_data(data) + return True + + +SELECTOR_MAPPINGS: tuple[YoLinkSelectEntityDescription, ...] = ( + YoLinkSelectEntityDescription( + key="model", + options=["auto", "manual", "off"], + translation_key="sprinkler_mode", + value=lambda data: ( + data.get("mode") if data is not None else None + ), # watering state report will missing state field + exists_fn=lambda device: device.device_type == ATTR_DEVICE_SPRINKLER, + should_update_entity=lambda value: value is not None, + on_option_selected=set_sprinker_mode_fn, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up YoLink select from a config entry.""" + device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + async_add_entities( + YoLinkSelectEntity(config_entry, selector_device_coordinator, description) + for selector_device_coordinator in device_coordinators.values() + if selector_device_coordinator.device.device_type in [ATTR_DEVICE_SPRINKLER] + for description in SELECTOR_MAPPINGS + if description.exists_fn(selector_device_coordinator.device) + ) + + +class YoLinkSelectEntity(YoLinkEntity, SelectEntity): + """YoLink Select Entity.""" + + entity_description: YoLinkSelectEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: YoLinkCoordinator, + description: YoLinkSelectEntityDescription, + ) -> None: + """Init YoLink Select.""" + super().__init__(config_entry, coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + + @callback + def update_entity_state(self, state: dict[str, Any]) -> None: + """Update HA Entity State.""" + if ( + current_value := self.entity_description.value( + state.get(self.entity_description.state_key) + ) + ) is None and self.entity_description.should_update_entity( + current_value + ) is False: + return + self._attr_current_option = current_value + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if await self.entity_description.on_option_selected(self.coordinator, option): + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index bc32d0eea83..3bb0e965eae 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -21,6 +21,10 @@ from yolink.const import ( ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SMOKE_ALARM, + ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SPRINKLER, + ATTR_DEVICE_SPRINKLER_V2, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_THERMOSTAT, @@ -42,6 +46,7 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfConductivity, UnitOfEnergy, UnitOfLength, UnitOfPower, @@ -55,6 +60,8 @@ from homeassistant.util import percentage from .const import ( DEV_MODEL_PLUG_YS6602_EC, DEV_MODEL_PLUG_YS6602_UC, + DEV_MODEL_PLUG_YS6614_EC, + DEV_MODEL_PLUG_YS6614_UC, DEV_MODEL_PLUG_YS6803_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_TH_SENSOR_YS8004_EC, @@ -103,6 +110,10 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, + ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, + ATTR_DEVICE_SPRINKLER, + ATTR_DEVICE_SPRINKLER_V2, ] BATTERY_POWER_SENSOR = [ @@ -122,12 +133,16 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, + ATTR_DEVICE_SPRINKLER_V2, ] MCU_DEV_TEMPERATURE_SENSOR = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_CO_SMOKE_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, ] NONE_HUMIDITY_SENSOR_MODELS = [ @@ -144,6 +159,8 @@ NONE_HUMIDITY_SENSOR_MODELS = [ POWER_SUPPORT_MODELS = [ DEV_MODEL_PLUG_YS6602_UC, DEV_MODEL_PLUG_YS6602_EC, + DEV_MODEL_PLUG_YS6614_UC, + DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_PLUG_YS6803_EC, ] @@ -182,7 +199,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, exists_fn=lambda device: ( - device.device_type in [ATTR_DEVICE_TH_SENSOR] + device.device_type in [ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_SOIL_TH_SENSOR] and device.device_model_name not in NONE_HUMIDITY_SENSOR_MODELS ), ), @@ -191,7 +208,8 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_TH_SENSOR], + exists_fn=lambda device: device.device_type + in [ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_SOIL_TH_SENSOR], ), # mcu temperature YoLinkSensorEntityDescription( @@ -206,7 +224,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( key="loraInfo", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - value=lambda value: value["signal"] if value is not None else None, + value=lambda value: value.get("signal") if value is not None else None, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -302,6 +320,23 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( exists_fn=lambda device: device.device_model_name in POWER_SUPPORT_MODELS, value=lambda value: value / 100 if value is not None else None, ), + YoLinkSensorEntityDescription( + key="conductivity", + device_class=SensorDeviceClass.CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, + state_class=SensorStateClass.MEASUREMENT, + exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SOIL_TH_SENSOR], + should_update_entity=lambda value: value is not None, + ), + YoLinkSensorEntityDescription( + key="coreTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + exists_fn=lambda device: device.device_model_name + in [DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6614_UC], + should_update_entity=lambda value: value is not None, + ), ) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 0eb9de97469..9e60b77f43a 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -47,6 +47,9 @@ "exceptions": { "invalid_config_entry": { "message": "Config entry not found or not loaded!" + }, + "valve_inoperable_currently": { + "message": "The Valve cannot be operated currently." } }, "entity": { @@ -118,6 +121,19 @@ }, "meter_valve_2_state": { "name": "Valve 2" + }, + "sprinkler_valve": { + "name": "[%key:component::valve::title%]" + } + }, + "select": { + "sprinkler_mode": { + "name": "Mode", + "state": { + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", + "off": "[%key:common::state::off%]" + } } } }, diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 06dee8af540..8361724a3cf 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -4,11 +4,14 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from yolink.client_request import ClientRequest from yolink.const import ( ATTR_DEVICE_MODEL_A, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SPRINKLER, + ATTR_DEVICE_SPRINKLER_V2, ATTR_DEVICE_WATER_METER_CONTROLLER, ) from yolink.device import YoLinkDevice @@ -21,6 +24,7 @@ from homeassistant.components.valve import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_MODEL_WATER_METER_YS5007, DOMAIN @@ -35,6 +39,20 @@ class YoLinkValveEntityDescription(ValveEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True value: Callable = lambda state: state channel_index: int | None = None + should_update_entity: Callable = lambda state: True + is_available: Callable[[YoLinkDevice, dict[str, Any]], bool] = ( + lambda device, state: True + ) + + +def sprinkler_valve_available(device: YoLinkDevice, data: dict[str, Any]) -> bool: + """Check if sprinkler valve is available.""" + if device.device_type == ATTR_DEVICE_SPRINKLER_V2: + return True + if (state := data.get("state")) is not None: + if (mode := state.get("mode")) is not None: + return mode == "manual" + return False DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( @@ -67,11 +85,24 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( ), channel_index=1, ), + YoLinkValveEntityDescription( + key="valve", + translation_key="sprinkler_valve", + device_class=ValveDeviceClass.WATER, + value=lambda value: value is False if value is not None else None, + exists_fn=lambda device: ( + device.device_type in [ATTR_DEVICE_SPRINKLER, ATTR_DEVICE_SPRINKLER_V2] + ), + should_update_entity=lambda value: value is not None, + is_available=sprinkler_valve_available, + ), ) DEVICE_TYPE = [ ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SPRINKLER, + ATTR_DEVICE_SPRINKLER_V2, ] @@ -123,13 +154,24 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): attr_val := self.entity_description.value( state.get(self.entity_description.key) ) - ) is None: + ) is None and self.entity_description.should_update_entity(attr_val) is False: return - self._attr_is_closed = attr_val + if self.entity_description.is_available(self.coordinator.device, state) is True: + self._attr_is_closed = attr_val + self._attr_available = True + else: + self._attr_available = False self.async_write_ha_state() async def _async_invoke_device(self, state: str) -> None: """Call setState api to change valve state.""" + if ( + self.coordinator.device.is_support_mode_switching() + and self.coordinator.dev_net_type == ATTR_DEVICE_MODEL_A + ): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="valve_inoperable_currently" + ) if ( self.coordinator.device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER @@ -139,6 +181,16 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): await self.call_device( ClientRequest("setState", {"valves": {str(channel_index): state}}) ) + if self.coordinator.device.device_type == ATTR_DEVICE_SPRINKLER: + await self.call_device( + ClientRequest( + "setManualWater", {"state": "start" if state == "open" else "stop"} + ) + ) + if self.coordinator.device.device_type == ATTR_DEVICE_SPRINKLER_V2: + await self.call_device( + ClientRequest("setState", {"running": state == "open"}) + ) else: await self.call_device(ClientRequest("setState", {"valve": state})) self._attr_is_closed = state == "close" @@ -155,10 +207,4 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): @property def available(self) -> bool: """Return true is device is available.""" - if ( - self.coordinator.device.is_support_mode_switching() - and self.coordinator.dev_net_type is not None - ): - # When the device operates in Class A mode, it cannot be controlled. - return self.coordinator.dev_net_type != ATTR_DEVICE_MODEL_A - return super().available + return self._attr_available and super().available diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index a1a71f6712e..56b0f0fdd3a 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==1.1.5"] + "requirements": ["youtubeaio==2.0.0"] } diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 524bac271de..432b5d50c4e 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -13,7 +13,7 @@ from urllib.parse import urljoin import voluptuous as vol from zabbix_utils import ItemValue, Sender, ZabbixAPI -from zabbix_utils.exceptions import APIRequestError +from zabbix_utils.exceptions import APIRequestError, ProcessingError from homeassistant.const import ( CONF_HOST, @@ -43,6 +43,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) CONF_PUBLISH_STATES_HOST = "publish_states_host" +CONF_PUBLISH_STRING_STATES = "publish_string_states" DEFAULT_SSL = False DEFAULT_PATH = "zabbix" @@ -67,6 +68,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PUBLISH_STATES_HOST): cv.string, + vol.Optional(CONF_PUBLISH_STRING_STATES, default=False): cv.boolean, } ) }, @@ -85,6 +87,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: password = conf.get(CONF_PASSWORD) publish_states_host = conf.get(CONF_PUBLISH_STATES_HOST) + publish_string_states = conf[CONF_PUBLISH_STRING_STATES] entities_filter = convert_include_exclude_filter(conf) @@ -107,6 +110,28 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = zapi + def update_metrics( + metrics: list[ItemValue], + item_type: str, + keys: set[str], + key_values: dict[str, float | str], + ): + keys_count = len(keys) + keys.update(key_values) + if len(keys) > keys_count: + discovery = [{"{#KEY}": key} for key in keys] + metric = ItemValue( + publish_states_host, + f"homeassistant.{item_type}s_discovery", + json.dumps(discovery), + ) + metrics.append(metric) + for key, value in key_values.items(): + metric = ItemValue( + publish_states_host, f"homeassistant.{item_type}[{key}]", value + ) + metrics.append(metric) + def event_to_metrics( event: Event, float_keys: set[str], string_keys: set[str] ) -> list[ItemValue] | None: @@ -119,8 +144,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: if not entities_filter(entity_id): return None - floats = {} - strings = {} + floats: dict[str, float | str] = {} + strings: dict[str, float | str] = {} try: _state_as_value = float(state.state) floats[entity_id] = _state_as_value @@ -129,7 +154,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _state_as_value = float(state_helper.state_as_number(state)) floats[entity_id] = _state_as_value except ValueError: - strings[entity_id] = state.state + if publish_string_states: + strings[entity_id] = str(state.state) for key, value in state.attributes.items(): # For each value we try to cast it as float @@ -141,28 +167,18 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: except (ValueError, TypeError): float_value = None if float_value is None or not math.isfinite(float_value): - strings[attribute_id] = str(value) + # Don't store string attributes for now + pass else: floats[attribute_id] = float_value - metrics = [] - float_keys_count = len(float_keys) - float_keys.update(floats) - if len(float_keys) != float_keys_count: - floats_discovery = [{"{#KEY}": float_key} for float_key in float_keys] - metric = ItemValue( - publish_states_host, - "homeassistant.floats_discovery", - json.dumps(floats_discovery), - ) - metrics.append(metric) - for key, value in floats.items(): - metric = ItemValue( - publish_states_host, f"homeassistant.float[{key}]", value - ) - metrics.append(metric) + metrics: list[ItemValue] = [] + update_metrics(metrics, "float", float_keys, floats) - string_keys.update(strings) + if not publish_string_states: + return metrics + + update_metrics(metrics, "string", string_keys, strings) return metrics if publish_states_host: @@ -266,6 +282,8 @@ class ZabbixThread(threading.Thread): if not self.write_errors: _LOGGER.error("Write error: %s", err) self.write_errors += len(metrics) + except ProcessingError as prerr: + _LOGGER.error("Error writing to Zabbix: %s", prerr) def run(self) -> None: """Process incoming events.""" diff --git a/homeassistant/components/zbox_hub/__init__.py b/homeassistant/components/zbox_hub/__init__.py new file mode 100644 index 00000000000..4635546852c --- /dev/null +++ b/homeassistant/components/zbox_hub/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Z-Box Hub.""" diff --git a/homeassistant/components/zbox_hub/manifest.json b/homeassistant/components/zbox_hub/manifest.json new file mode 100644 index 00000000000..b3aa28e9af8 --- /dev/null +++ b/homeassistant/components/zbox_hub/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "zbox_hub", + "name": "Z-Box Hub", + "integration_type": "virtual", + "supported_by": "fibaro" +} diff --git a/homeassistant/components/zeversolar/entity.py b/homeassistant/components/zeversolar/entity.py index 18ac4dcde32..3e085d952ca 100644 --- a/homeassistant/components/zeversolar/entity.py +++ b/homeassistant/components/zeversolar/entity.py @@ -27,4 +27,5 @@ class ZeversolarEntity( identifiers={(DOMAIN, coordinator.data.serial_number)}, name="Zeversolar Sensor", manufacturer="Zeversolar", + serial_number=coordinator.data.serial_number, ) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 6c5fcba1f8b..4383aa52afa 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -8,6 +8,7 @@ from typing import Any from zha.application.const import ATTR_IEEE from zha.application.gateway import Gateway +from zigpy.application import ControllerApplication from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.types import Channels @@ -63,6 +64,19 @@ def shallow_asdict(obj: Any) -> dict: return obj +def get_application_state_diagnostics(app: ControllerApplication) -> dict: + """Dump the application state as a dictionary.""" + data = shallow_asdict(app.state) + + # EUI64 objects in zigpy are not subclasses of any JSON-serializable key type and + # must be converted to strings. + data["network_info"]["nwk_addresses"] = { + str(k): v for k, v in data["network_info"]["nwk_addresses"].items() + } + + return data + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: @@ -79,7 +93,7 @@ async def async_get_config_entry_diagnostics( { "config": zha_data.yaml_config, "config_entry": config_entry.as_dict(), - "application_state": shallow_asdict(app.state), + "application_state": get_application_state_diagnostics(app), "energy_scan": { channel: 100 * energy / 255 for channel, energy in energy_scan.items() }, diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 084e1c882ac..f5b44eb8fc4 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -74,7 +74,12 @@ from zha.event import EventBase from zha.exceptions import ZHAException from zha.mixins import LogMixin from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent -from zha.zigbee.device import ClusterHandlerConfigurationComplete, Device, ZHAEvent +from zha.zigbee.device import ( + ClusterHandlerConfigurationComplete, + Device, + DeviceFirmwareInfoUpdatedEvent, + ZHAEvent, +) from zha.zigbee.group import Group, GroupInfo, GroupMember from zigpy.config import ( CONF_DATABASE, @@ -843,8 +848,23 @@ class ZHAGatewayProxy(EventBase): name=zha_device.name, manufacturer=zha_device.manufacturer, model=zha_device.model, + sw_version=zha_device.firmware_version, ) zha_device_proxy.device_id = device_registry_device.id + + def update_sw_version(event: DeviceFirmwareInfoUpdatedEvent) -> None: + """Update software version in device registry.""" + device_registry.async_update_device( + device_registry_device.id, + sw_version=event.new_firmware_version, + ) + + self._unsubs.append( + zha_device.on_event( + DeviceFirmwareInfoUpdatedEvent.event_type, update_sw_version + ) + ) + return zha_device_proxy def _async_get_or_create_group_proxy(self, group_info: GroupInfo) -> ZHAGroupProxy: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2ba35d1b1ad..e980d34402b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.60"], + "requirements": ["zha==0.0.68"], "usb": [ { "vid": "10C4", @@ -106,6 +106,12 @@ "pid": "EA60", "description": "*sonoff*max*", "known_devices": ["SONOFF Dongle Max MG24"] + }, + { + "vid": "10C4", + "pid": "EA60", + "description": "*sonoff*lite*mg21*", + "known_devices": ["sonoff zigbee dongle lite mg21"] } ], "zeroconf": [ diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 1327a78b0b3..1c9454ec0a0 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -616,6 +616,18 @@ }, "water_supply": { "name": "Water supply" + }, + "frient_in_1": { + "name": "IN1" + }, + "frient_in_2": { + "name": "IN2" + }, + "frient_in_3": { + "name": "IN3" + }, + "frient_in_4": { + "name": "IN4" } }, "button": { @@ -639,6 +651,9 @@ }, "frost_lock_reset": { "name": "Frost lock reset" + }, + "reset_alarm": { + "name": "Reset alarm" } }, "climate": { @@ -1118,7 +1133,7 @@ "name": "Comfort temperature" }, "valve_state_auto_shutdown": { - "name": "Valve state auto shutdown" + "name": "Valve state auto-shutdown" }, "shutdown_timer": { "name": "Shutdown timer" @@ -1155,6 +1170,21 @@ }, "update_frequency": { "name": "Update frequency" + }, + "sound_volume": { + "name": "Sound volume" + }, + "lift_drive_up_time": { + "name": "Lift drive up time" + }, + "lift_drive_down_time": { + "name": "Lift drive down time" + }, + "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" } }, "select": { @@ -1204,7 +1234,7 @@ "name": "Smart fan LED display levels" }, "increased_non_neutral_output": { - "name": "Non neutral output" + "name": "Increased non-neutral output" }, "leading_or_trailing_edge": { "name": "Dimming mode" @@ -1388,6 +1418,12 @@ }, "external_switch_type": { "name": "External switch type" + }, + "switch_indication": { + "name": "Switch indication" + }, + "switch_actions": { + "name": "Switch actions" } }, "sensor": { @@ -1451,6 +1487,9 @@ "tier6_summation_delivered": { "name": "Tier 6 summation delivered" }, + "total_active_power": { + "name": "Total power" + }, "summation_received": { "name": "Summation received" }, @@ -1741,6 +1780,32 @@ }, "lifetime": { "name": "Lifetime" + }, + "last_action_source": { + "name": "Last action source", + "state": { + "zigbee": "Zigbee", + "keypad": "Keypad", + "fingerprint": "Fingerprint", + "rfid": "RFID", + "self": "Self" + } + }, + "last_action": { + "name": "Last action", + "state": { + "lock": "[%key:common::state::locked%]", + "unlock": "[%key:common::state::unlocked%]" + } + }, + "last_action_user": { + "name": "Last action user" + }, + "last_pin_code": { + "name": "Last PIN code" + }, + "opening": { + "name": "Opening" } }, "switch": { @@ -1949,13 +2014,28 @@ "name": "Schedule mode" }, "auto_clean": { - "name": "Auto clean" + "name": "Autoclean" }, "test_mode": { "name": "Test mode" }, "external_temperature_sensor": { "name": "External temperature sensor" + }, + "auto_relock": { + "name": "Autorelock" + }, + "distance_tracking": { + "name": "Distance tracking" + }, + "water_shortage_auto_close": { + "name": "Water shortage auto-close" + }, + "frient_com_1": { + "name": "COM 1" + }, + "frient_com_2": { + "name": "COM 2" } } } diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 062581fd259..867e4ff2dd3 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -58,7 +58,7 @@ async def async_setup_entry( zha_data = get_zha_data(hass) if zha_data.update_coordinator is None: zha_data.update_coordinator = ZHAFirmwareUpdateCoordinator( - hass, get_zha_gateway(hass).application_controller + hass, config_entry, get_zha_gateway(hass).application_controller ) entities_to_create = zha_data.platforms[Platform.UPDATE] @@ -79,12 +79,16 @@ class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disa """Firmware update coordinator that broadcasts updates network-wide.""" def __init__( - self, hass: HomeAssistant, controller_application: ControllerApplication + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + controller_application: ControllerApplication, ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="ZHA firmware update coordinator", update_method=self.async_update_data, ) diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json index 3e019d2f053..58a56c97830 100644 --- a/homeassistant/components/zimi/manifest.json +++ b/homeassistant/components/zimi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/zimi", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["zcc-helper==3.5.2"] + "requirements": ["zcc-helper==3.6"] } diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml index 98e6c5b627c..8b8b85c71f4 100644 --- a/homeassistant/components/zimi/quality_scale.yaml +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -16,6 +16,7 @@ rules: status: done comment: | https://mark_hannon@bitbucket.org/mark_hannon/zcc.git + https://bitbucket.org/mark_hannon/zcc/src/master/bitbucket-pipelines.yml docs-actions: status: exempt comment: | diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py new file mode 100644 index 00000000000..cc2429ed3a4 --- /dev/null +++ b/homeassistant/components/zone/condition.py @@ -0,0 +1,156 @@ +"""Offer zone automation rules.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_CONDITION, + CONF_ENTITY_ID, + CONF_ZONE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ( + Condition, + ConditionCheckerType, + trace_condition_function, +) +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import in_zone + +_CONDITION_SCHEMA = vol.Schema( + { + **cv.CONDITION_BASE_SCHEMA, + vol.Required(CONF_CONDITION): "zone", + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required("zone"): cv.entity_ids, + # To support use_trigger_value in automation + # Deprecated 2016/04/25 + vol.Optional("event"): vol.Any("enter", "leave"), + } +) + + +def zone( + hass: HomeAssistant, + zone_ent: str | State | None, + entity: str | State | None, +) -> bool: + """Test if zone-condition matches. + + Async friendly. + """ + if zone_ent is None: + raise ConditionErrorMessage("zone", "no zone specified") + + if isinstance(zone_ent, str): + zone_ent_id = zone_ent + + if (zone_ent := hass.states.get(zone_ent)) is None: + raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}") + + if entity is None: + raise ConditionErrorMessage("zone", "no entity specified") + + if isinstance(entity, str): + entity_id = entity + + if (entity := hass.states.get(entity)) is None: + raise ConditionErrorMessage("zone", f"unknown entity {entity_id}") + else: + entity_id = entity.entity_id + + if entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + return False + + latitude = entity.attributes.get(ATTR_LATITUDE) + longitude = entity.attributes.get(ATTR_LONGITUDE) + + if latitude is None: + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'latitude' attribute" + ) + + if longitude is None: + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'longitude' attribute" + ) + + return in_zone( + zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0) + ) + + +class ZoneCondition(Condition): + """Zone condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + + async def async_get_checker(self) -> ConditionCheckerType: + """Wrap action method with zone based condition.""" + entity_ids = self._config.get(CONF_ENTITY_ID, []) + zone_entity_ids = self._config.get(CONF_ZONE, []) + + @trace_condition_function + def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Test if condition.""" + errors = [] + + all_ok = True + for entity_id in entity_ids: + entity_ok = False + for zone_entity_id in zone_entity_ids: + try: + if zone(hass, zone_entity_id, entity_id): + entity_ok = True + except ConditionErrorMessage as ex: + errors.append( + ConditionErrorMessage( + "zone", + ( + f"error matching {entity_id} with {zone_entity_id}:" + f" {ex.message}" + ), + ) + ) + + if not entity_ok: + all_ok = False + + # Raise the errors only if no definitive result was found + if errors and not all_ok: + raise ConditionErrorContainer("zone", errors=errors) + + return all_ok + + return if_in_zone + + +CONDITIONS: dict[str, type[Condition]] = { + "_": ZoneCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index af4999e5438..59e0f2f8821 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -22,7 +22,6 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import ( - condition, config_validation as cv, entity_registry as er, location, @@ -31,6 +30,8 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType +from . import condition + EVENT_ENTER = "enter" EVENT_LEAVE = "leave" DEFAULT_EVENT = EVENT_ENTER diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 241c2729653..27b69a8d62d 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .services import register_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ex, ) - register_services(hass) + async_setup_services(hass) hass.async_create_task( async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) diff --git a/homeassistant/components/zoneminder/services.py b/homeassistant/components/zoneminder/services.py index 14ce873ec14..53847213c85 100644 --- a/homeassistant/components/zoneminder/services.py +++ b/homeassistant/components/zoneminder/services.py @@ -5,7 +5,7 @@ import logging import voluptuous as vol from homeassistant.const import ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import DOMAIN @@ -32,7 +32,8 @@ def _set_active_state(call: ServiceCall) -> None: ) -def register_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register ZoneMinder services.""" hass.services.async_register( diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 0b172c20715..af42f024e6a 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -29,7 +29,7 @@ from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.components.persistent_notification import async_create -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, @@ -104,9 +104,7 @@ from .const import ( CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, - DATA_CLIENT, DOMAIN, - DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, EVENT_VALUE_UPDATED, LIB_LOGGER, @@ -133,10 +131,11 @@ from .helpers import ( get_valueless_base_unique_id, ) from .migrate import async_migrate_discovered_value +from .models import ZwaveJSConfigEntry, ZwaveJSData from .services import async_setup_services CONNECT_TIMEOUT = 10 -DATA_DRIVER_EVENTS = "driver_events" +DRIVER_READY_TIMEOUT = 60 CONFIG_SCHEMA = vol.Schema( { @@ -148,6 +147,7 @@ CONFIG_SCHEMA = vol.Schema( }, extra=vol.ALLOW_EXTRA, ) +MIN_CONTROLLER_FIRMWARE_SDK_VERSION = AwesomeVersion("6.50.0") PLATFORMS = [ Platform.BINARY_SENSOR, @@ -182,7 +182,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: ZwaveJSConfigEntry) -> bool: """Set up Z-Wave JS from a config entry.""" if use_addon := entry.data.get(CONF_USE_ADDON): await async_ensure_addon_running(hass, entry) @@ -260,10 +260,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Connection to Zwave JS Server initialized") - entry_runtime_data = entry.runtime_data = { - DATA_CLIENT: client, - } - entry_runtime_data[DATA_DRIVER_EVENTS] = driver_events = DriverEvents(hass, entry) + driver_events = DriverEvents(hass, entry) + entry_runtime_data = ZwaveJSData( + client=client, + driver_events=driver_events, + ) + entry.runtime_data = entry_runtime_data driver = client.driver # When the driver is ready we know it's set on the client. @@ -276,39 +278,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # and we'll handle the clean up below. await driver_events.setup(driver) - if (old_unique_id := entry.unique_id) is not None and old_unique_id != ( - new_unique_id := str(driver.controller.home_id) - ): - device_registry = dr.async_get(hass) - controller_model = "Unknown model" - if ( - (own_node := driver.controller.own_node) - and ( - controller_device_entry := device_registry.async_get_device( - identifiers={get_device_id(driver, own_node)} - ) - ) - and (model := controller_device_entry.model) - ): - controller_model = model - async_create_issue( - hass, - DOMAIN, - f"migrate_unique_id.{entry.entry_id}", - data={ - "config_entry_id": entry.entry_id, - "config_entry_title": entry.title, - "controller_model": controller_model, - "new_unique_id": new_unique_id, - "old_unique_id": old_unique_id, - }, - is_fixable=True, - severity=IssueSeverity.ERROR, - translation_key="migrate_unique_id", - ) - else: - async_delete_issue(hass, DOMAIN, f"migrate_unique_id.{entry.entry_id}") - # If the listen task is already failed, we need to raise ConfigEntryNotReady if listen_task.done(): listen_error, error_message = _get_listen_task_error(listen_task) @@ -348,7 +317,7 @@ class DriverEvents: driver: Driver - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> None: """Set up the driver events instance.""" self.config_entry = entry self.dev_reg = dr.async_get(hass) @@ -386,28 +355,6 @@ class DriverEvents: self.hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) ) - # Check for nodes that no longer exist and remove them - stored_devices = dr.async_entries_for_config_entry( - self.dev_reg, self.config_entry.entry_id - ) - known_devices = [ - self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) - for node in controller.nodes.values() - ] - provisioned_devices = [ - self.dev_reg.async_get(entry.additional_properties["device_id"]) - for entry in await controller.async_get_provisioning_entries() - if entry.additional_properties - and "device_id" in entry.additional_properties - ] - - # Devices that are in the device registry that are not known by the controller - # can be removed - if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): - for device in stored_devices: - if device not in known_devices and device not in provisioned_devices: - self.dev_reg.async_remove_device(device.id) - # run discovery on controller node if controller.own_node: await self.controller_events.async_on_node_added(controller.own_node) @@ -421,6 +368,16 @@ class DriverEvents: ) ) + # listen for driver ready event to reload the config entry + self.config_entry.async_on_unload( + driver.on( + "driver ready", + lambda _: self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ), + ) + ) + # listen for new nodes being added to the mesh self.config_entry.async_on_unload( controller.on( @@ -442,6 +399,72 @@ class DriverEvents: controller.on("identify", self.controller_events.async_on_identify) ) + if ( + old_unique_id := self.config_entry.unique_id + ) is not None and old_unique_id != ( + new_unique_id := str(driver.controller.home_id) + ): + device_registry = dr.async_get(self.hass) + controller_model = "Unknown model" + if ( + (own_node := driver.controller.own_node) + and ( + controller_device_entry := device_registry.async_get_device( + identifiers={get_device_id(driver, own_node)} + ) + ) + and (model := controller_device_entry.model) + ): + controller_model = model + + # Do not clean up old stale devices if an unknown controller is connected. + data = {**self.config_entry.data, CONF_KEEP_OLD_DEVICES: True} + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + async_create_issue( + self.hass, + DOMAIN, + f"migrate_unique_id.{self.config_entry.entry_id}", + data={ + "config_entry_id": self.config_entry.entry_id, + "config_entry_title": self.config_entry.title, + "controller_model": controller_model, + "new_unique_id": new_unique_id, + "old_unique_id": old_unique_id, + }, + is_fixable=True, + severity=IssueSeverity.ERROR, + translation_key="migrate_unique_id", + ) + else: + data = self.config_entry.data.copy() + data.pop(CONF_KEEP_OLD_DEVICES, None) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + async_delete_issue( + self.hass, DOMAIN, f"migrate_unique_id.{self.config_entry.entry_id}" + ) + + # Check for nodes that no longer exist and remove them + stored_devices = dr.async_entries_for_config_entry( + self.dev_reg, self.config_entry.entry_id + ) + known_devices = [ + self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) + for node in controller.nodes.values() + ] + provisioned_devices = [ + self.dev_reg.async_get(entry.additional_properties["device_id"]) + for entry in await controller.async_get_provisioning_entries() + if entry.additional_properties + and "device_id" in entry.additional_properties + ] + + # Devices that are in the device registry that are not known by the controller + # can be removed + if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): + for device in stored_devices: + if device not in known_devices and device not in provisioned_devices: + self.dev_reg.async_remove_device(device.id) + class ControllerEvents: """Represent controller events. @@ -486,7 +509,7 @@ class ControllerEvents: ) ) - await self.async_check_preprovisioned_device(node) + await self.async_check_pre_provisioned_device(node) if node.is_controller_node: # Create a controller status sensor for each device @@ -614,8 +637,8 @@ class ControllerEvents: f"{DOMAIN}.identify_controller.{dev_id[1]}", ) - async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None: - """Check if the node was preprovisioned and update the device registry.""" + async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None: + """Check if the node was pre-provisioned and update the device registry.""" provisioning_entry = ( await self.driver_events.driver.controller.async_get_provisioning_entry( node.node_id @@ -625,29 +648,37 @@ class ControllerEvents: provisioning_entry and provisioning_entry.additional_properties and "device_id" in provisioning_entry.additional_properties - ): - preprovisioned_device = self.dev_reg.async_get( - provisioning_entry.additional_properties["device_id"] + and ( + pre_provisioned_device := self.dev_reg.async_get( + provisioning_entry.additional_properties["device_id"] + ) ) + and (dsk_identifier := (DOMAIN, f"provision_{provisioning_entry.dsk}")) + in pre_provisioned_device.identifiers + ): + driver = self.driver_events.driver + device_id = get_device_id(driver, node) + device_id_ext = get_device_id_ext(driver, node) + new_identifiers = pre_provisioned_device.identifiers.copy() + new_identifiers.remove(dsk_identifier) + new_identifiers.add(device_id) + if device_id_ext: + new_identifiers.add(device_id_ext) - if preprovisioned_device: - dsk = provisioning_entry.dsk - dsk_identifier = (DOMAIN, f"provision_{dsk}") - - # If the pre-provisioned device has the DSK identifier, remove it - if dsk_identifier in preprovisioned_device.identifiers: - driver = self.driver_events.driver - device_id = get_device_id(driver, node) - device_id_ext = get_device_id_ext(driver, node) - new_identifiers = preprovisioned_device.identifiers.copy() - new_identifiers.remove(dsk_identifier) - new_identifiers.add(device_id) - if device_id_ext: - new_identifiers.add(device_id_ext) - self.dev_reg.async_update_device( - preprovisioned_device.id, - new_identifiers=new_identifiers, - ) + if self.dev_reg.async_get_device(identifiers=new_identifiers): + # If a device entry is registered with the node ID based identifiers, + # just remove the device entry with the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + else: + # Add the node ID based identifiers to the device entry + # with the DSK identifier and remove the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + new_identifiers=new_identifiers, + ) async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" @@ -787,11 +818,19 @@ class NodeEvents: node.on("notification", self.async_on_notification) ) - # Create a firmware update entity for each non-controller device that + # Create a firmware update entity for each device that # supports firmware updates - if not node.is_controller_node and any( - cc.id == CommandClass.FIRMWARE_UPDATE_MD.value - for cc in node.command_classes + controller = self.controller_events.driver_events.driver.controller + if ( + not (is_controller_node := node.is_controller_node) + and any( + cc.id == CommandClass.FIRMWARE_UPDATE_MD.value + for cc in node.command_classes + ) + ) or ( + is_controller_node + and (sdk_version := controller.sdk_version) is not None + and sdk_version >= MIN_CONTROLLER_FIRMWARE_SDK_VERSION ): async_dispatcher_send( self.hass, @@ -1045,7 +1084,7 @@ class NodeEvents: async def client_listen( hass: HomeAssistant, - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: ZwaveClient, driver_ready: asyncio.Event, ) -> None: @@ -1053,31 +1092,40 @@ async def client_listen( try: await client.listen(driver_ready) except BaseZwaveJSServerError as err: - if entry.state is not ConfigEntryState.LOADED: + if entry.state is ConfigEntryState.SETUP_IN_PROGRESS: raise LOGGER.error("Client listen failed: %s", err) except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) - if entry.state is not ConfigEntryState.LOADED: + if entry.state is ConfigEntryState.SETUP_IN_PROGRESS: raise + if hass.is_stopping or entry.state is ConfigEntryState.UNLOAD_IN_PROGRESS: + return + + if entry.state is ConfigEntryState.SETUP_IN_PROGRESS: + raise HomeAssistantError("Listen task ended unexpectedly") + # The entry needs to be reloaded since a new driver state # will be acquired on reconnect. # All model instances will be replaced when the new state is acquired. - if not hass.is_stopping: - if entry.state is not ConfigEntryState.LOADED: - raise HomeAssistantError("Listen task ended unexpectedly") + if entry.state.recoverable: LOGGER.debug("Disconnected from server. Reloading integration") hass.config_entries.async_schedule_reload(entry.entry_id) + else: + LOGGER.error( + "Disconnected from server. Cannot recover entry %s", + entry.title, + ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) entry_runtime_data = entry.runtime_data - client: ZwaveClient = entry_runtime_data[DATA_CLIENT] + client = entry_runtime_data.client if client.connected and (driver := client.driver): await async_disable_server_logging_if_needed(hass, entry, driver) @@ -1094,7 +1142,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> None: """Remove a config entry.""" if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON): return @@ -1116,7 +1164,9 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) -async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_ensure_addon_running( + hass: HomeAssistant, entry: ZwaveJSConfigEntry +) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" addon_manager = _get_addon_manager(hass) try: diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 168df5edcaa..b392b1c95cd 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2,12 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine from contextlib import suppress import dataclasses from functools import partial, wraps -from typing import Any, Concatenate, Literal, cast +from typing import TYPE_CHECKING, Any, Concatenate, Literal, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -70,7 +69,7 @@ from homeassistant.components.websocket_api import ( ERR_UNKNOWN_ERROR, ActiveConnection, ) -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -86,9 +85,7 @@ from .const import ( ATTR_WAIT_FOR_RESULT, CONF_DATA_COLLECTION_OPTED_IN, CONF_INSTALLER_MODE, - DATA_CLIENT, DOMAIN, - DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, LOGGER, USER_AGENT, @@ -99,9 +96,14 @@ from .helpers import ( async_get_node_from_device_id, async_get_provisioning_entry_from_device_id, async_get_version_info, + async_wait_for_driver_ready_event, get_device_id, ) +if TYPE_CHECKING: + from .models import ZwaveJSConfigEntry + + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -254,7 +256,7 @@ async def _async_get_entry( connection: ActiveConnection, msg: dict[str, Any], entry_id: str, -) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: +) -> tuple[ZwaveJSConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -269,7 +271,7 @@ async def _async_get_entry( ) return None, None, None - client: Client = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client if client.driver is None: connection.send_error( @@ -284,7 +286,14 @@ async def _async_get_entry( def async_get_entry( orig_func: Callable[ - [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + [ + HomeAssistant, + ActiveConnection, + dict[str, Any], + ZwaveJSConfigEntry, + Client, + Driver, + ], Coroutine[Any, Any, None], ], ) -> Callable[ @@ -726,7 +735,7 @@ async def websocket_add_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -903,7 +912,7 @@ async def websocket_cancel_secure_bootstrap_s2( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -926,7 +935,7 @@ async def websocket_subscribe_s2_inclusion( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -979,7 +988,7 @@ async def websocket_grant_security_classes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1007,7 +1016,7 @@ async def websocket_validate_dsk_and_enter_pin( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1077,7 +1086,7 @@ async def websocket_provision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1162,7 +1171,7 @@ async def websocket_unprovision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1212,7 +1221,7 @@ async def websocket_get_provisioning_entries( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1236,7 +1245,7 @@ async def websocket_parse_qr_code_string( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1262,7 +1271,7 @@ async def websocket_try_parse_dsk_from_qr_code_string( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1291,7 +1300,7 @@ async def websocket_lookup_device( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1323,7 +1332,7 @@ async def websocket_supports_feature( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1349,7 +1358,7 @@ async def websocket_stop_inclusion( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1376,7 +1385,7 @@ async def websocket_stop_exclusion( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1404,7 +1413,7 @@ async def websocket_remove_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1692,7 +1701,7 @@ async def websocket_begin_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1719,7 +1728,7 @@ async def websocket_subscribe_rebuild_routes_progress( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1772,7 +1781,7 @@ async def websocket_stop_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2100,7 +2109,7 @@ async def websocket_subscribe_log_updates( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2187,7 +2196,7 @@ async def websocket_update_log_config( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2211,7 +2220,7 @@ async def websocket_get_log_config( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2238,7 +2247,7 @@ async def websocket_update_data_collection_preference( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2273,7 +2282,7 @@ async def websocket_data_collection_status( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2507,7 +2516,7 @@ async def websocket_is_any_ota_firmware_update_in_progress( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2602,7 +2611,7 @@ async def websocket_check_for_config_updates( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2631,7 +2640,7 @@ async def websocket_install_config_update( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2670,7 +2679,7 @@ async def websocket_subscribe_controller_statistics( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2823,7 +2832,7 @@ async def websocket_hard_reset_controller( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2844,26 +2853,18 @@ async def websocket_hard_reset_controller( connection.send_result(msg[ID], device.id) async_cleanup() - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - msg[DATA_UNSUBSCRIBE] = unsubs = [ async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added ), - driver.once("driver ready", set_driver_ready), ] + wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver) + await driver.async_hard_reset() with suppress(TimeoutError): - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - + await wait_for_driver_ready() # When resetting the controller, the controller home id is also changed. # The controller state in the client is stale after resetting the controller, # so get the new home id with a new client using the helper function. @@ -2876,14 +2877,14 @@ async def websocket_hard_reset_controller( # The stale unique id needs to be handled by a repair flow, # after the config entry has been reloaded. LOGGER.error( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller reset" ) else: hass.config_entries.async_update_entry( entry, unique_id=str(version_info.home_id) ) - await hass.config_entries.async_reload(entry.entry_id) + hass.config_entries.async_schedule_reload(entry.entry_id) @websocket_api.websocket_command( @@ -3000,7 +3001,7 @@ async def websocket_backup_nvm( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -3062,7 +3063,7 @@ async def websocket_restore_nvm( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -3090,27 +3091,19 @@ async def websocket_restore_nvm( ) ) - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - # Set up subscription for progress events connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [ controller.on("nvm convert progress", forward_progress), controller.on("nvm restore progress", forward_progress), - driver.once("driver ready", set_driver_ready), ] - await controller.async_restore_nvm_base64(msg["data"]) + wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver) + + await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False}) with suppress(TimeoutError): - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - + await wait_for_driver_ready() # When restoring the NVM to the controller, the controller home id is also changed. # The controller state in the client is stale after restoring the NVM, # so get the new home id with a new client using the helper function. @@ -3123,14 +3116,13 @@ async def websocket_restore_nvm( # The stale unique id needs to be handled by a repair flow, # after the config entry has been reloaded. LOGGER.error( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller NVM restore" ) else: hass.config_entries.async_update_entry( entry, unique_id=str(version_info.home_id) ) - await hass.config_entries.async_reload(entry.entry_id) connection.send_message( @@ -3142,3 +3134,4 @@ async def websocket_restore_nvm( ) ) connection.send_result(msg[ID]) + async_cleanup() diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index d70690ace31..5b7fe4f4d7c 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass -from zwave_js_server.client import Client as ZwaveClient 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 ( @@ -18,15 +17,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -364,11 +363,11 @@ def is_valid_notification_binary_sensor( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave binary sensor from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None: @@ -448,7 +447,7 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -476,7 +475,7 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, state_key: str, @@ -509,7 +508,7 @@ class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, description: PropertyZWaveJSEntityDescription, @@ -533,7 +532,7 @@ class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor): _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterBinarySensor entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index f3a1d5af04d..36bca858b50 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -2,32 +2,31 @@ from __future__ import annotations -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave button from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_button(info: ZwaveDiscoveryInfo) -> None: @@ -70,7 +69,7 @@ class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity): """Representation of a ZWave button entity for a boolean value.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize entity.""" super().__init__(config_entry, driver, info) @@ -141,7 +140,7 @@ class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveNotificationIdleButton entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 809d3543fe4..5d3b1f8ef07 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -31,18 +30,18 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import DynamicCurrentTempClimateDataTemplate from .entity import ZWaveBaseEntity from .helpers import get_value_of_zwave_value +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -96,11 +95,11 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave climate from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_climate(info: ZwaveDiscoveryInfo) -> None: @@ -130,7 +129,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize thermostat.""" super().__init__(config_entry, driver, info) @@ -563,7 +562,7 @@ class DynamicCurrentTempClimate(ZWaveClimate): """Representation of a thermostat that can dynamically use a different Zwave Value for current temp.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize thermostat.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 5e8e7022839..b72a71279ab 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -27,7 +27,6 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_USB, - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -40,7 +39,6 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from homeassistant.helpers.typing import VolDictType from .addon import get_addon_manager from .const import ( @@ -63,11 +61,14 @@ from .const import ( CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, - DATA_CLIENT, DOMAIN, - DRIVER_READY_TIMEOUT, ) -from .helpers import CannotConnect, async_get_version_info +from .helpers import ( + CannotConnect, + async_get_version_info, + async_wait_for_driver_ready_event, +) +from .models import ZwaveJSConfigEntry _LOGGER = logging.getLogger(__name__) @@ -87,9 +88,21 @@ ADDON_USER_INPUT_MAP = { CONF_ADDON_LR_S2_AUTHENTICATED_KEY: CONF_LR_S2_AUTHENTICATED_KEY, } +EXAMPLE_SERVER_URL = "ws://localhost:3000" ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") +NETWORK_TYPE_NEW = "new" +NETWORK_TYPE_EXISTING = "existing" +ZWAVE_JS_SERVER_INSTRUCTIONS = ( + "https://www.home-assistant.io/integrations/zwave_js/" + "#advanced-installation-instructions" +) +ZWAVE_JS_UI_MIGRATION_INSTRUCTIONS = ( + "https://www.home-assistant.io/integrations/zwave_js/" + "#how-to-migrate-from-one-adapter-to-a-new-adapter-using-z-wave-js-ui" +) + def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: """Return a schema for the manual step.""" @@ -138,13 +151,15 @@ def get_usb_ports() -> dict[str, str]: ) port_descriptions[dev_path] = human_name - # Sort the dictionary by description, putting "n/a" last - return dict( - sorted( - port_descriptions.items(), - key=lambda x: x[1].lower().startswith("n/a"), - ) - ) + # Filter out "n/a" descriptions only if there are other ports available + non_na_ports = { + path: desc + for path, desc in port_descriptions.items() + if not desc.lower().startswith("n/a") + } + + # If we have non-"n/a" ports, return only those; otherwise return all ports as-is + return non_na_ports if non_na_ports else port_descriptions async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: @@ -181,7 +196,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.backup_filepath: Path | None = None self.use_addon = False self._migrating = False - self._reconfigure_config_entry: ConfigEntry | None = None + self._reconfigure_config_entry: ZwaveJSConfigEntry | None = None self._usb_discovery = False self._recommended_install = False @@ -440,7 +455,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): None, ) if not self._reconfigure_config_entry: - return self.async_abort(reason="addon_required") + return self.async_abort( + reason="addon_required", + description_placeholders={ + "zwave_js_ui_migration": ZWAVE_JS_UI_MIGRATION_INSTRUCTIONS, + }, + ) vid = discovery_info.vid pid = discovery_info.pid @@ -491,17 +511,35 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._usb_discovery = True if current_config_entries: - return await self.async_step_intent_migrate() + return await self.async_step_confirm_usb_migration() return await self.async_step_installation_type() + async def async_step_confirm_usb_migration( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm USB migration.""" + if user_input is not None: + return await self.async_step_intent_migrate() + return self.async_show_form( + step_id="confirm_usb_migration", + description_placeholders={ + "usb_title": self.context["title_placeholders"][CONF_NAME], + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a manual configuration.""" if user_input is None: return self.async_show_form( - step_id="manual", data_schema=get_manual_schema({}) + step_id="manual", + data_schema=get_manual_schema({}), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, ) errors = {} @@ -530,7 +568,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self._async_create_entry_from_vars() return self.async_show_form( - step_id="manual", data_schema=get_manual_schema(user_input), errors=errors + step_id="manual", + data_schema=get_manual_schema(user_input), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, + errors=errors, ) async def async_step_hassio( @@ -630,6 +674,81 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" + + if user_input is not None: + self.usb_path = user_input[CONF_USB_PATH] + return await self.async_step_network_type() + + if self._usb_discovery: + return await self.async_step_network_type() + + usb_path = self.usb_path or "" + + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), + } + ) + + return self.async_show_form( + step_id="configure_addon_user", data_schema=data_schema + ) + + async def async_step_network_type( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for network type (new or existing).""" + # For recommended installation, automatically set network type to "new" + if self._recommended_install: + user_input = {"network_type": NETWORK_TYPE_NEW} + + if user_input is not None: + if user_input["network_type"] == NETWORK_TYPE_NEW: + # Set all keys to empty strings for new network + self.s0_legacy_key = "" + self.s2_access_control_key = "" + self.s2_authenticated_key = "" + self.s2_unauthenticated_key = "" + self.lr_s2_access_control_key = "" + self.lr_s2_authenticated_key = "" + + addon_config_updates = { + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + } + + await self._async_set_addon_config(addon_config_updates) + return await self.async_step_start_addon() + + # Network already exists, go to security keys step + return await self.async_step_configure_security_keys() + + return self.async_show_form( + step_id="network_type", + data_schema=vol.Schema( + { + vol.Required("network_type", default=""): vol.In( + [NETWORK_TYPE_NEW, NETWORK_TYPE_EXISTING] + ) + } + ), + ) + + async def async_step_configure_security_keys( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for security keys for existing Z-Wave network.""" addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -652,10 +771,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" ) - if self._recommended_install and self._usb_discovery: - # Recommended installation with USB discovery, skip asking for keys - user_input = {} - if user_input is not None: self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, s0_legacy_key) self.s2_access_control_key = user_input.get( @@ -673,8 +788,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.lr_s2_authenticated_key = user_input.get( CONF_LR_S2_AUTHENTICATED_KEY, lr_s2_authenticated_key ) - if not self._usb_discovery: - self.usb_path = user_input[CONF_USB_PATH] addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, @@ -687,14 +800,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } await self._async_set_addon_config(addon_config_updates) - return await self.async_step_start_addon() - usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" - schema: VolDictType = ( - {} - if self._recommended_install - else { + data_schema = vol.Schema( + { vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, vol.Optional( CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key @@ -714,22 +823,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } ) - if not self._usb_discovery: - try: - ports = await async_get_usb_ports(self.hass) - except OSError as err: - _LOGGER.error("Failed to get USB ports: %s", err) - return self.async_abort(reason="usb_ports_failed") - - schema = { - vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), - **schema, - } - - data_schema = vol.Schema(schema) - return self.async_show_form( - step_id="configure_addon_user", data_schema=data_schema + step_id="configure_security_keys", data_schema=data_schema ) async def async_step_finish_addon_setup_user( @@ -820,7 +915,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): config_entry = self._reconfigure_config_entry assert config_entry is not None if not self._usb_discovery and not config_entry.data.get(CONF_USE_ADDON): - return self.async_abort(reason="addon_required") + return self.async_abort( + reason="addon_required", + description_placeholders={ + "zwave_js_ui_migration": ZWAVE_JS_UI_MIGRATION_INSTRUCTIONS, + }, + ) try: driver = self._get_driver() @@ -843,11 +943,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - if user_input is not None: - self._migrating = True - return await self.async_step_backup_nvm() - - return self.async_show_form(step_id="intent_migrate") + self._migrating = True + return await self.async_step_backup_nvm() async def async_step_backup_nvm( self, user_input: dict[str, Any] | None = None @@ -902,7 +999,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_instruct_unplug( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Reset the current controller, and instruct the user to unplug it.""" + """Instruct the user to unplug the old controller.""" if user_input is not None: if self.usb_path: @@ -912,63 +1009,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # Now that the old controller is gone, we can scan for serial ports again return await self.async_step_choose_serial_port() - try: - driver = self._get_driver() - except AbortFlow: - return self.async_abort(reason="config_entry_not_loaded") - - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - - unsubscribe = driver.once("driver ready", set_driver_ready) - - # reset the old controller - try: - await driver.async_hard_reset() - except FailedCommand as err: - unsubscribe() - _LOGGER.error("Failed to reset controller: %s", err) - return self.async_abort(reason="reset_failed") - - # Update the unique id of the config entry - # to the new home id, which requires waiting for the driver - # to be ready before getting the new home id. - # If the backup restore, done later in the flow, fails, - # the config entry unique id should be the new home id - # after the controller reset. - try: - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - except TimeoutError: - pass - finally: - unsubscribe() - config_entry = self._reconfigure_config_entry assert config_entry is not None - try: - version_info = await async_get_version_info( - self.hass, config_entry.data[CONF_URL] - ) - except CannotConnect: - # Just log this error, as there's nothing to do about it here. - # The stale unique id needs to be handled by a repair flow, - # after the config entry has been reloaded, if the backup restore - # also fails. - _LOGGER.debug( - "Failed to get server version, cannot update config entry " - "unique id with new home id, after controller reset" - ) - else: - self.hass.config_entries.async_update_entry( - config_entry, unique_id=str(version_info.home_id) - ) - # Unload the config entry before asking the user to unplug the controller. await self.hass.config_entries.async_unload(config_entry.entry_id) @@ -989,6 +1032,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="manual_reconfigure", data_schema=get_manual_schema({CONF_URL: config_entry.data[CONF_URL]}), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, ) errors = {} @@ -1019,6 +1066,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="manual_reconfigure", data_schema=get_manual_schema(user_input), + description_placeholders={ + "example_server_url": EXAMPLE_SERVER_URL, + "server_instructions": ZWAVE_JS_SERVER_INSTRUCTIONS, + }, errors=errors, ) @@ -1386,27 +1437,24 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): event["bytesWritten"] / event["total"] * 0.5 + 0.5 ) - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - driver = self._get_driver() controller = driver.controller - wait_driver_ready = asyncio.Event() unsubs = [ controller.on("nvm convert progress", forward_progress), controller.on("nvm restore progress", forward_progress), - driver.once("driver ready", set_driver_ready), ] + + wait_for_driver_ready = async_wait_for_driver_ready_event(config_entry, driver) + try: - await controller.async_restore_nvm(self.backup_data) + await controller.async_restore_nvm( + self.backup_data, {"preserveRoutes": False} + ) except FailedCommand as err: raise AbortFlow(f"Failed to restore network: {err}") from err else: with suppress(TimeoutError): - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() + await wait_for_driver_ready() try: version_info = await async_get_version_info( self.hass, config_entry.data[CONF_URL] @@ -1423,10 +1471,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry( config_entry, unique_id=str(version_info.home_id) ) - await self.hass.config_entries.async_reload(config_entry.entry_id) - # Reload the config entry two times to clean up - # the stale device entry. + # The config entry will be also be reloaded when the driver is ready, + # by the listener in the package module, + # and two reloads are needed to clean up the stale controller device entry. # Since both the old and the new controller have the same node id, # but different hardware identifiers, the integration # will create a new device for the new controller, on the first reload, @@ -1443,7 +1491,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): assert config_entry is not None if config_entry.state != ConfigEntryState.LOADED: raise AbortFlow("Configuration entry is not loaded") - client: Client = config_entry.runtime_data[DATA_CLIENT] + client: Client = config_entry.runtime_data.client assert client.driver is not None return client.driver diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index a99e9fd0113..69987385d5a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -38,8 +38,6 @@ CONF_USE_ADDON = "use_addon" CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" DOMAIN = "zwave_js" -DATA_CLIENT = "client" -DATA_OLD_SERVER_LOG_LEVEL = "old_server_log_level" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" EVENT_VALUE_UPDATED = "value updated" @@ -94,7 +92,6 @@ ATTR_CURRENT_VALUE = "current_value" ATTR_CURRENT_VALUE_RAW = "current_value_raw" ATTR_DESCRIPTION = "description" ATTR_EVENT_SOURCE = "event_source" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PARTIAL_DICT_MATCH = "partial_dict_match" # service constants @@ -203,7 +200,3 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = { WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, } - -# Other constants - -DRIVER_READY_TIMEOUT = 60 diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index dc44f46a3ce..424fe94b8b9 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( CURRENT_VALUE_PROPERTY, TARGET_STATE_PROPERTY, @@ -34,31 +33,26 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - COVER_POSITION_PROPERTY_KEYS, - COVER_TILT_PROPERTY_KEYS, - DATA_CLIENT, - DOMAIN, -) +from .const import COVER_POSITION_PROPERTY_KEYS, COVER_TILT_PROPERTY_KEYS, DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import CoverTiltDataTemplate from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Cover from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_cover(info: ZwaveDiscoveryInfo) -> None: @@ -288,7 +282,7 @@ class ZWaveMultilevelSwitchCover(CoverPositionMixin): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -318,7 +312,7 @@ class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -336,7 +330,7 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): """Representation of a Z-Wave Window Covering cover device.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize.""" super().__init__(config_entry, driver, info) @@ -438,7 +432,7 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 4eed2a5b50c..27c9ff2bd34 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -2,14 +2,13 @@ from __future__ import annotations -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.value import ConfigurationValue from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN NODE_STATUSES = ["asleep", "awake", "dead", "alive"] @@ -55,5 +54,5 @@ def async_bypass_dynamic_config_validation(hass: HomeAssistant, device_id: str) return True # The driver may not be ready when the config entry is loaded. - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client return client.driver is None diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 5515100b20b..1929341a4be 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -13,13 +13,12 @@ from zwave_js_server.model.value import ValueDataType from zwave_js_server.util.node import dump_node_state from homeassistant.components.diagnostics import REDACTED, async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DATA_CLIENT, USER_AGENT +from .const import USER_AGENT from .helpers import ( ZwaveValueMatcher, get_home_and_node_id_from_device_entry, @@ -27,6 +26,7 @@ from .helpers import ( get_value_id_from_unique_id, value_matches_matcher, ) +from .models import ZwaveJSConfigEntry KEYS_TO_REDACT = {"homeId", "location"} @@ -73,7 +73,10 @@ def redact_node_state(node_state: dict) -> dict: def get_device_entities( - hass: HomeAssistant, node: Node, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, + node: Node, + config_entry: ZwaveJSConfigEntry, + device: dr.DeviceEntry, ) -> list[dict[str, Any]]: """Get entities for a device.""" entity_entries = er.async_entries_for_device( @@ -125,7 +128,7 @@ def get_device_entities( async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ZwaveJSConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" msgs: list[dict] = async_redact_data( @@ -144,10 +147,10 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, device: dr.DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - client: Client = config_entry.runtime_data[DATA_CLIENT] + client: Client = config_entry.runtime_data.client identifiers = get_home_and_node_id_from_device_entry(device) node_id = identifiers[1] if identifiers else None driver = client.driver diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 3b541a733cc..7030009f5ad 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -263,7 +263,7 @@ WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( ) # For device class mapping see: -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json +# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/ DISCOVERY_SCHEMAS = [ # ====== START OF DEVICE SPECIFIC MAPPING SCHEMAS ======= # Honeywell 39358 In-Wall Fan Control using switch multilevel CC @@ -291,12 +291,16 @@ DISCOVERY_SCHEMAS = [ FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]), ), ), - # GE/Jasco - In-Wall Smart Fan Control - 14287 / 55258 / ZW4002 + # GE/Jasco - In-Wall Smart Fan Controls ZWaveDiscoverySchema( platform=Platform.FAN, hint="has_fan_value_mapping", manufacturer_id={0x0063}, - product_id={0x3131, 0x3337}, + product_id={ + 0x3131, + 0x3337, # 14287 / 55258 / ZW4002 + 0x3533, # 58446 / ZWA4013 + }, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=FixedFanValueMappingDataTemplate( @@ -756,7 +760,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SELECT, hint="multilevel_switch", manufacturer_id={0x0084}, - product_id={0x0107, 0x0108, 0x010B, 0x0205}, + product_id={0x0107, 0x0108, 0x0109, 0x010B, 0x0205}, product_type={0x0311, 0x0313, 0x0331, 0x0341, 0x0343}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=BaseDiscoverySchemaDataTemplate( @@ -768,6 +772,35 @@ DISCOVERY_SCHEMAS = [ }, ), ), + # ZWA-2, discover LED control as configuration, default disabled + ## Production firmware (1.0) -> Color Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="zwa2_led_color", + primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + absent_values=[ + SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), + ## Day-1 firmware update (1.1) -> Binary Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="zwa2_led_onoff", + primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + absent_values=[ + COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index d1ab9009308..08a587d8d20 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Sequence from typing import Any -from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import ( @@ -27,8 +26,6 @@ from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id EVENT_VALUE_REMOVED = "value removed" -EVENT_DEAD = "dead" -EVENT_ALIVE = "alive" class ZWaveBaseEntity(Entity): @@ -141,11 +138,6 @@ class ZWaveBaseEntity(Entity): ) ) - for status_event in (EVENT_ALIVE, EVENT_DEAD): - self.async_on_remove( - self.info.node.on(status_event, self._node_status_alive_or_dead) - ) - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -211,19 +203,7 @@ class ZWaveBaseEntity(Entity): @property def available(self) -> bool: """Return entity availability.""" - return ( - self.driver.client.connected - and bool(self.info.node.ready) - and self.info.node.status != NodeStatus.DEAD - ) - - @callback - def _node_status_alive_or_dead(self, event_data: dict) -> None: - """Call when node status changes to alive or dead. - - Should not be overridden by subclasses. - """ - self.async_write_ha_state() + return self.driver.client.connected and bool(self.info.node.ready) @callback def _value_changed(self, event_data: dict) -> None: diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py index 66959aa9b75..60f0e110108 100644 --- a/homeassistant/components/zwave_js/event.py +++ b/homeassistant/components/zwave_js/event.py @@ -2,30 +2,29 @@ from __future__ import annotations -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN +from .const import ATTR_VALUE, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Event entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_event(info: ZwaveDiscoveryInfo) -> None: @@ -56,7 +55,7 @@ class ZwaveEventEntity(ZWaveBaseEntity, EventEntity): """Representation of a Z-Wave event entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveEventEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index ae36e0afb42..8e47cbbeb1d 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -5,7 +5,6 @@ from __future__ import annotations import math from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE from zwave_js_server.const.command_class.thermostat import ( @@ -20,7 +19,6 @@ from homeassistant.components.fan import ( FanEntity, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -30,11 +28,12 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import FanValueMapping, FanValueMappingDataTemplate from .entity import ZWaveBaseEntity from .helpers import get_value_of_zwave_value +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -45,11 +44,11 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Fan from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_fan(info: ZwaveDiscoveryInfo) -> None: @@ -85,7 +84,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): ) def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the fan.""" super().__init__(config_entry, driver, info) @@ -165,7 +164,7 @@ class ValueMappingZwaveFan(ZwaveFan): """A Zwave fan with a value mapping data (e.g., 1-24 is low).""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the fan.""" super().__init__(config_entry, driver, info) @@ -316,7 +315,7 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): _fan_state: ZwaveValue | None = None def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the thermostat fan.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index bfa093f7db9..17f4909662c 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -3,14 +3,13 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import astuple, dataclass import logging from typing import Any, cast import aiohttp import voluptuous as vol -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( LOG_LEVEL_MAP, CommandClass, @@ -30,7 +29,7 @@ from zwave_js_server.model.value import ( from zwave_js_server.version import VersionInfo, get_server_version from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, ATTR_DEVICE_ID, @@ -51,13 +50,13 @@ from .const import ( ATTR_ENDPOINT, ATTR_PROPERTY, ATTR_PROPERTY_KEY, - DATA_CLIENT, - DATA_OLD_SERVER_LOG_LEVEL, DOMAIN, LIB_LOGGER, LOGGER, ) +from .models import ZwaveJSConfigEntry +DRIVER_READY_EVENT_TIMEOUT = 60 SERVER_VERSION_TIMEOUT = 10 @@ -143,7 +142,7 @@ async def async_enable_statistics(driver: Driver) -> None: async def async_enable_server_logging_if_needed( - hass: HomeAssistant, entry: ConfigEntry, driver: Driver + hass: HomeAssistant, entry: ZwaveJSConfigEntry, driver: Driver ) -> None: """Enable logging of zwave-js-server in the lib.""" # If lib log level is set to debug, we want to enable server logging. First we @@ -161,15 +160,14 @@ async def async_enable_server_logging_if_needed( if (curr_server_log_level := driver.log_config.level) and ( LOG_LEVEL_MAP[curr_server_log_level] ) > LIB_LOGGER.getEffectiveLevel(): - entry_data = entry.runtime_data - entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level + entry.runtime_data.old_server_log_level = curr_server_log_level await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) await driver.client.enable_server_logging() LOGGER.info("Zwave-js-server logging is enabled") async def async_disable_server_logging_if_needed( - hass: HomeAssistant, entry: ConfigEntry, driver: Driver + hass: HomeAssistant, entry: ZwaveJSConfigEntry, driver: Driver ) -> None: """Disable logging of zwave-js-server in the lib if still connected to server.""" if ( @@ -180,10 +178,8 @@ async def async_disable_server_logging_if_needed( return LOGGER.info("Disabling zwave_js server logging") if ( - DATA_OLD_SERVER_LOG_LEVEL in entry.runtime_data - and (old_server_log_level := entry.runtime_data.pop(DATA_OLD_SERVER_LOG_LEVEL)) - != driver.log_config.level - ): + old_server_log_level := entry.runtime_data.old_server_log_level + ) is not None and old_server_log_level != driver.log_config.level: LOGGER.info( ( "Server logging is currently set to %s as a result of server logging " @@ -193,6 +189,7 @@ async def async_disable_server_logging_if_needed( old_server_log_level, ) await driver.async_update_log_config(LogConfig(level=old_server_log_level)) + entry.runtime_data.old_server_log_level = None driver.client.disable_server_logging() LOGGER.info("Zwave-js-server logging is enabled") @@ -262,7 +259,7 @@ def async_get_node_from_device_id( # Use device config entry ID's to validate that this is a valid zwave_js device # and to get the client config_entry_ids = device_entry.config_entries - entry = next( + entry: ZwaveJSConfigEntry | None = next( ( entry for entry in hass.config_entries.async_entries(DOMAIN) @@ -277,7 +274,7 @@ def async_get_node_from_device_id( if entry.state != ConfigEntryState.LOADED: raise ValueError(f"Device {device_id} config entry is not loaded") - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client driver = client.driver if driver is None: @@ -310,7 +307,7 @@ async def async_get_provisioning_entry_from_device_id( # Use device config entry ID's to validate that this is a valid zwave_js device # and to get the client config_entry_ids = device_entry.config_entries - entry = next( + entry: ZwaveJSConfigEntry | None = next( ( entry for entry in hass.config_entries.async_entries(DOMAIN) @@ -325,7 +322,7 @@ async def async_get_provisioning_entry_from_device_id( if entry.state != ConfigEntryState.LOADED: raise ValueError(f"Device {device_id} config entry is not loaded") - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client driver = client.driver if driver is None: @@ -393,7 +390,7 @@ def async_get_nodes_from_area_id( for device in dr.async_entries_for_area(dev_reg, area_id) if any( cast( - ConfigEntry, + ZwaveJSConfigEntry, hass.config_entries.async_get_entry(config_entry_id), ).domain == DOMAIN @@ -487,7 +484,7 @@ def async_get_node_status_sensor_entity_id( entry = hass.config_entries.async_get_entry(entry_id) assert entry - client = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client node = async_get_node_from_device_id(hass, device_id, dev_reg) return ent_reg.async_get_entity_id( SENSOR_DOMAIN, @@ -565,7 +562,7 @@ def get_device_info(driver: Driver, node: ZwaveNode) -> DeviceInfo: def get_network_identifier_for_notification( - hass: HomeAssistant, config_entry: ConfigEntry, controller: Controller + hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, controller: Controller ) -> str: """Return the network identifier string for persistent notifications.""" home_id = str(controller.home_id) @@ -592,5 +589,57 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio return version_info +@callback +def async_wait_for_driver_ready_event( + config_entry: ZwaveJSConfigEntry, + driver: Driver, +) -> Callable[[], Coroutine[Any, Any, None]]: + """Wait for the driver ready event and the config entry reload. + + When the driver ready event is received + the config entry will be reloaded by the integration. + This function helps wait for that to happen + before proceeding with further actions. + + If the config entry is reloaded for another reason, + this function will not wait for it to be reloaded again. + + Raises TimeoutError if the driver ready event and reload + is not received within the specified timeout. + """ + driver_ready_event_received = asyncio.Event() + config_entry_reloaded = asyncio.Event() + unsubscribers: list[Callable[[], None]] = [] + + @callback + def driver_ready_received(event: dict) -> None: + """Receive the driver ready event.""" + driver_ready_event_received.set() + + unsubscribers.append(driver.once("driver ready", driver_ready_received)) + + @callback + def on_config_entry_state_change() -> None: + """Check config entry was loaded after driver ready event.""" + if config_entry.state is ConfigEntryState.LOADED: + config_entry_reloaded.set() + + unsubscribers.append( + config_entry.async_on_state_change(on_config_entry_state_change) + ) + + async def wait_for_events() -> None: + try: + async with asyncio.timeout(DRIVER_READY_EVENT_TIMEOUT): + await asyncio.gather( + driver_ready_event_received.wait(), config_entry_reloaded.wait() + ) + finally: + for unsubscribe in unsubscribers: + unsubscribe() + + return wait_for_events + + class CannotConnect(HomeAssistantError): """Indicate connection error.""" diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 2b85bd4449f..83f5e507c01 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -5,7 +5,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.humidity_control import ( HUMIDITY_CONTROL_SETPOINT_PROPERTY, @@ -23,14 +22,14 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -69,11 +68,11 @@ DEHUMIDIFIER_ENTITY_DESCRIPTION = ZwaveHumidifierEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave humidifier from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_humidifier(info: ZwaveDiscoveryInfo) -> None: @@ -122,7 +121,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, description: ZwaveHumidifierEntityDescription, diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index f60e129cc77..9b7c0222410 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( TARGET_VALUE_PROPERTY, TRANSITION_DURATION_OPTION, @@ -38,15 +37,15 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -66,11 +65,11 @@ MAX_MIREDS = 370 # 2700K as a safe default async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Light from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_light(info: ZwaveDiscoveryInfo) -> None: @@ -78,7 +77,11 @@ async def async_setup_entry( driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - if info.platform_hint == "color_onoff": + if info.platform_hint == "zwa2_led_color": + async_add_entities([ZWA2LEDColorLight(config_entry, driver, info)]) + elif info.platform_hint == "zwa2_led_onoff": + async_add_entities([ZWA2LEDOnOffLight(config_entry, driver, info)]) + elif info.platform_hint == "color_onoff": async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)]) else: async_add_entities([ZwaveLight(config_entry, driver, info)]) @@ -109,7 +112,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): _attr_max_color_temp_kelvin = 6500 # 153 mireds as a safe default def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the light.""" super().__init__(config_entry, driver, info) @@ -184,7 +187,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self._supports_color_temp: self._supported_color_modes.add(ColorMode.COLOR_TEMP) if not self._supported_color_modes: - self._supported_color_modes.add(ColorMode.BRIGHTNESS) + if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY: + self._supported_color_modes.add(ColorMode.ONOFF) + else: + self._supported_color_modes.add(ColorMode.BRIGHTNESS) self._calculate_color_values() # Entity class attributes @@ -539,7 +545,7 @@ class ZwaveColorOnOffLight(ZwaveLight): """ def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the light.""" super().__init__(config_entry, driver, info) @@ -678,3 +684,29 @@ class ZwaveColorOnOffLight(ZwaveLight): colors, kwargs.get(ATTR_TRANSITION), ) + + +class ZWA2LEDColorLight(ZwaveColorOnOffLight): + """LED entity specific to the ZWA-2 (legacy firmware).""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" + + +class ZWA2LEDOnOffLight(ZwaveLight): + """LED entity specific to the ZWA-2.""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index f609084955c..6e22afd3d2d 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any import voluptuous as vol -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, @@ -20,7 +19,6 @@ from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.util.lock import clear_usercode, set_configuration, set_usercode from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity, LockState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -34,7 +32,6 @@ from .const import ( ATTR_LOCK_TIMEOUT, ATTR_OPERATION_TYPE, ATTR_TWIST_ASSIST, - DATA_CLIENT, DOMAIN, LOGGER, SERVICE_CLEAR_LOCK_USERCODE, @@ -43,6 +40,7 @@ from .const import ( ) from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -61,11 +59,11 @@ UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave lock from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_lock(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 082a3dd9f95..153e8e6a7fe 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.64.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.67.1"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/models.py b/homeassistant/components/zwave_js/models.py new file mode 100644 index 00000000000..63f77871c14 --- /dev/null +++ b/homeassistant/components/zwave_js/models.py @@ -0,0 +1,27 @@ +"""Type definitions for Z-Wave JS integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from zwave_js_server.const import LogLevel + +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from zwave_js_server.client import Client as ZwaveClient + + from . import DriverEvents + + +@dataclass +class ZwaveJSData: + """Data for zwave_js runtime data.""" + + client: ZwaveClient + driver_events: DriverEvents + old_server_log_level: LogLevel | None = None + + +type ZwaveJSConfigEntry = ConfigEntry[ZwaveJSData] diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 2e2d93bbdbe..982966ce3a9 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -5,33 +5,32 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN +from .const import ATTR_RESERVED_VALUES, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Number entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_number(info: ZwaveDiscoveryInfo) -> None: @@ -62,7 +61,7 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): """Representation of a Z-Wave number entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveNumberEntity entity.""" super().__init__(config_entry, driver, info) @@ -114,7 +113,7 @@ class ZWaveConfigParameterNumberEntity(ZwaveNumberEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterNumber entity.""" super().__init__(config_entry, driver, info) @@ -142,7 +141,7 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): """Representation of a volume number entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveVolumeNumberEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index f1deb91d869..072a330a7bd 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -90,6 +90,7 @@ class MigrateUniqueIDFlow(RepairsFlow): config_entry, unique_id=self.description_placeholders["new_unique_id"], ) + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) return self.async_create_entry(data={}) return self.async_show_form( diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 8a6ccc57c17..b8c84d02c95 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -4,33 +4,32 @@ from __future__ import annotations from typing import cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass from zwave_js_server.const.command_class.lock import TARGET_MODE_PROPERTY from zwave_js_server.const.command_class.sound_switch import TONE_ID_PROPERTY, ToneID from zwave_js_server.model.driver import Driver from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Select entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_select(info: ZwaveDiscoveryInfo) -> None: @@ -69,7 +68,7 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveSelectEntity entity.""" super().__init__(config_entry, driver, info) @@ -103,7 +102,7 @@ class ZWaveDoorLockSelectEntity(ZwaveSelectEntity): """Representation of a Z-Wave door lock CC mode select entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveDoorLockSelectEntity entity.""" super().__init__(config_entry, driver, info) @@ -126,7 +125,7 @@ class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterSelect entity.""" super().__init__(config_entry, driver, info) @@ -145,7 +144,7 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveDefaultToneSelectEntity entity.""" super().__init__(config_entry, driver, info) @@ -194,7 +193,7 @@ class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave Multilevel Switch CC select entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveSelectEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 05fa785760b..23b906a9d16 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,16 +4,15 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, cast import voluptuous as vol -from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass +from zwave_js_server.const import CommandClass, RssiError from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) -from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.exceptions import BaseZwaveJSServerError, RssiErrorReceived from zwave_js_server.model.controller import Controller from zwave_js_server.model.controller.statistics import ControllerStatistics from zwave_js_server.model.driver import Driver @@ -28,7 +27,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, @@ -56,7 +54,6 @@ from .const import ( ATTR_METER_TYPE, ATTR_METER_TYPE_NAME, ATTR_VALUE, - DATA_CLIENT, DOMAIN, ENTITY_DESC_KEY_BATTERY_LEVEL, ENTITY_DESC_KEY_BATTERY_LIST_STATE, @@ -94,6 +91,7 @@ from .discovery_data_template import ( from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id from .migrate import async_migrate_statistics_sensors +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -423,7 +421,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -431,7 +429,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -440,7 +438,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -448,7 +446,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -457,7 +455,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -465,13 +463,30 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, convert=convert_nested_attr, ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.average", + translation_key="avg_signal_noise", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_nested_attr, + ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.current", + translation_key="signal_noise", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + convert=convert_nested_attr, + ), ] CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { @@ -490,6 +505,8 @@ CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { "background_rssi.channel_1.current": "backgroundRSSI.channel1.current", "background_rssi.channel_2.average": "backgroundRSSI.channel2.average", "background_rssi.channel_2.current": "backgroundRSSI.channel2.current", + "background_rssi.channel_3.average": "backgroundRSSI.channel3.average", + "background_rssi.channel_3.current": "backgroundRSSI.channel3.current", } # Node statistics descriptions @@ -532,7 +549,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="rssi", - translation_key="rssi", + translation_key="signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -541,7 +558,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, + entity_registry_enabled_default=False, ), ] @@ -576,11 +593,11 @@ def get_entity_description( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. @@ -717,7 +734,7 @@ class ZwaveSensor(ZWaveBaseEntity, SensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -756,7 +773,7 @@ class ZWaveNumericSensor(ZwaveSensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -831,7 +848,7 @@ class ZWaveListSensor(ZwaveSensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -870,7 +887,7 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -906,7 +923,7 @@ class ZWaveNodeStatusSensor(SensorEntity): _attr_translation_key = "node_status" def __init__( - self, config_entry: ConfigEntry, driver: Driver, node: ZwaveNode + self, config_entry: ZwaveJSConfigEntry, driver: Driver, node: ZwaveNode ) -> None: """Initialize a generic Z-Wave device entity.""" self.config_entry = config_entry @@ -968,7 +985,7 @@ class ZWaveControllerStatusSensor(SensorEntity): _attr_has_entity_name = True _attr_translation_key = "controller_status" - def __init__(self, config_entry: ConfigEntry, driver: Driver) -> None: + def __init__(self, config_entry: ZwaveJSConfigEntry, driver: Driver) -> None: """Initialize a generic Z-Wave device entity.""" self.config_entry = config_entry self.controller = driver.controller @@ -1030,9 +1047,9 @@ class ZWaveStatisticsSensor(SensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, - statistics_src: ZwaveNode | Controller, + statistics_src: Controller | ZwaveNode, description: ZWaveJSStatisticsSensorEntityDescription, ) -> None: """Initialize a Z-Wave statistics entity.""" @@ -1063,13 +1080,31 @@ class ZWaveStatisticsSensor(SensorEntity): ) @callback - def statistics_updated(self, event_data: dict) -> None: + def _statistics_updated(self, event_data: dict) -> None: """Call when statistics updated event is received.""" - self._attr_native_value = self.entity_description.convert( - event_data["statistics_updated"], self.entity_description.key + statistics = cast( + ControllerStatistics | NodeStatistics, event_data["statistics_updated"] ) + self._set_statistics(statistics) self.async_write_ha_state() + @callback + def _set_statistics( + self, statistics: ControllerStatistics | NodeStatistics + ) -> None: + """Set updated statistics.""" + try: + self._attr_native_value = self.entity_description.convert( + statistics, self.entity_description.key + ) + except RssiErrorReceived as err: + if err.error is RssiError.NOT_AVAILABLE: + self._attr_available = False + return + self._attr_native_value = None + # Reset available state. + self._attr_available = True + async def async_added_to_hass(self) -> None: """Call when entity is added.""" self.async_on_remove( @@ -1087,10 +1122,8 @@ class ZWaveStatisticsSensor(SensorEntity): ) ) self.async_on_remove( - self.statistics_src.on("statistics updated", self.statistics_updated) + self.statistics_src.on("statistics updated", self._statistics_updated) ) # Set initial state - self._attr_native_value = self.entity_description.convert( - self.statistics_src.statistics, self.entity_description.key - ) + self._set_statistics(self.statistics_src.statistics) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 076e3b6a50d..9420159b806 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -704,7 +704,7 @@ class ZWaveServices: client = first_node.client except StopIteration: data = self._hass.config_entries.async_entries(const.DOMAIN)[0].runtime_data - client = data[const.DATA_CLIENT] + client = data.client assert client.driver first_node = next( node diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index f0526171a70..f63a3bb9144 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const.command_class.sound_switch import ToneID from zwave_js_server.model.driver import Driver @@ -15,25 +14,25 @@ from homeassistant.components.siren import ( SirenEntity, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Siren entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_siren(info: ZwaveDiscoveryInfo) -> None: @@ -57,7 +56,7 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): """Representation of a Z-Wave siren entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveSirenEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d9a3b82a47c..0ff635578ea 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -4,7 +4,7 @@ "addon_get_discovery_info_failed": "Failed to get Z-Wave add-on discovery info.", "addon_info_failed": "Failed to get Z-Wave add-on info.", "addon_install_failed": "Failed to install the Z-Wave add-on.", - "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.", + "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. 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 add-on.", "addon_stop_failed": "Failed to stop the Z-Wave add-on.", @@ -15,12 +15,12 @@ "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "migration_low_sdk_version": "The SDK version of the old controller is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old controller to another controller.\n\nCheck the documentation on the manufacturer support pages of the old controller, if it's possible to upgrade the firmware of the old controller to a version that is build with SDK version {ok_sdk_version} or higher.", + "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_zwave_device": "Discovered device is not a Z-Wave device.", "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "reset_failed": "Failed to reset controller.", + "reset_failed": "Failed to reset adapter.", "usb_ports_failed": "Failed to get USB devices." }, "error": { @@ -39,25 +39,37 @@ "step": { "configure_addon_user": { "data": { - "lr_s2_access_control_key": "Long Range S2 Access Control Key", - "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", - "s0_legacy_key": "S0 Key (Legacy)", - "s2_access_control_key": "S2 Access Control Key", - "s2_authenticated_key": "S2 Authenticated Key", - "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "Select your Z-Wave adapter", "title": "Enter the Z-Wave add-on configuration" }, + "network_type": { + "data": { + "network_type": "Is your network new or does it already exist?" + }, + "title": "Z-Wave network" + }, + "configure_security_keys": { + "data": { + "lr_s2_access_control_key": "Long Range S2 Access Control Key", + "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key" + }, + "description": "Enter the security keys for your existing Z-Wave network", + "title": "Security keys" + }, "configure_addon_reconfigure": { "data": { - "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]", - "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_access_control_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_unauthenticated_key%]", + "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_access_control_key%]", + "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_authenticated_key%]", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_unauthenticated_key%]", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]", @@ -70,13 +82,21 @@ "title": "Installing add-on" }, "manual": { + "description": "The Z-Wave integration requires a running Z-Wave Server. If you don't already have that set up, please read the [instructions]({server_instructions}) in our documentation.\n\nWhen you have a Z-Wave Server running, enter its URL below to allow the integration to connect.", "data": { "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The URL of the Z-Wave Server WebSocket API, e.g. {example_server_url}" } }, "manual_reconfigure": { + "description": "[%key:component::zwave_js::config::step::manual::description%]", "data": { "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::zwave_js::config::step::manual::data_description::url%]" } }, "on_supervisor": { @@ -96,29 +116,29 @@ "start_addon": { "title": "Configuring add-on" }, + "confirm_usb_migration": { + "description": "You are about to migrate your Z-Wave network from the old adapter to the new adapter {usb_title}. This will take a backup of the network from the old adapter and restore the network to the new adapter.\n\nPress Submit to continue with the migration.", + "title": "Migrate to a new adapter" + }, "zeroconf_confirm": { "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", "title": "Discovered Z-Wave Server" }, "reconfigure": { "title": "Migrate or re-configure", - "description": "Are you migrating to a new controller or re-configuring the current controller?", + "description": "Are you migrating to a new adapter or re-configuring the current adapter?", "menu_options": { - "intent_migrate": "Migrate to a new controller", - "intent_reconfigure": "Re-configure the current controller" + "intent_migrate": "Migrate to a new adapter", + "intent_reconfigure": "Re-configure the current adapter" } }, - "intent_migrate": { - "title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]", - "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?" - }, "instruct_unplug": { - "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." + "title": "Unplug your old adapter", + "description": "Backup saved to \"{file_path}\"\n\nYour old adapter has not been reset. You should now unplug it to prevent it from interfering with the new adapter.\n\nPlease make sure your new adapter is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", - "description": "Your Z-Wave network could not be restored to the new controller. 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'>'", + "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'>'", "submit": "Try again" }, "choose_serial_port": { @@ -191,8 +211,8 @@ } }, "sensor": { - "average_background_rssi": { - "name": "Average background RSSI (channel {channel})" + "avg_signal_noise": { + "name": "Avg. signal noise (channel {channel})" }, "can": { "name": "Collisions" @@ -208,9 +228,6 @@ "unresponsive": "Unresponsive" } }, - "current_background_rssi": { - "name": "Current background RSSI (channel {channel})" - }, "last_seen": { "name": "Last seen" }, @@ -230,12 +247,15 @@ "unknown": "Unknown" } }, - "rssi": { - "name": "RSSI" - }, "rtt": { "name": "Round trip time" }, + "signal_noise": { + "name": "Signal noise (channel {channel})" + }, + "signal_strength": { + "name": "Signal strength" + }, "successful_commands": { "name": "Successful commands ({direction})" }, @@ -262,7 +282,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and the device must be re-interviewed to pick up the changes.\n\nThis is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery-powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device-specific and is normally explained in the device manual.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" @@ -281,12 +301,12 @@ "fix_flow": { "step": { "confirm": { - "description": "A Z-Wave controller of model {controller_model} with a different ID ({new_unique_id}) than the previously connected controller ({old_unique_id}) was connected to the {config_entry_title} configuration entry.\n\nReasons for a different controller ID could be:\n\n1. The controller was factory reset, with a 3rd party application.\n2. A controller Non Volatile Memory (NVM) backup was restored to the controller, with a 3rd party application.\n3. A different controller was connected to this configuration entry.\n\nIf a different controller was connected, you should instead set up a new configuration entry for the new controller.\n\nIf you are sure that the current controller is the correct controller you can confirm this by pressing Submit, and the configuration entry will remember the new controller ID.", - "title": "An unknown controller was detected" + "description": "A Z-Wave adapter of model {controller_model} was connected to the {config_entry_title} configuration entry. This adapter has a different ID ({new_unique_id}) than the previously connected adapter ({old_unique_id}).\n\nReasons for a different adapter ID could be:\n\n1. The adapter was factory reset using a 3rd party application.\n2. A backup of the adapter's non-volatile memory was restored to the adapter using a 3rd party application.\n3. A different adapter was connected to this configuration entry.\n\nIf a different adapter was connected, you should instead set up a new configuration entry for the new adapter.\n\nIf you are sure that the current adapter is the correct adapter, confirm by pressing Submit. The configuration entry will remember the new adapter ID.", + "title": "An unknown adapter was detected" } } }, - "title": "An unknown controller was detected" + "title": "An unknown adapter was detected" } }, "services": { @@ -540,8 +560,8 @@ "description": "Sets the configuration for a lock.", "fields": { "auto_relock_time": { - "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`.", - "name": "Auto relock time" + "description": "Duration in seconds until lock returns to locked state. Only enforced when operation type is `constant`.", + "name": "Autorelock time" }, "block_to_block": { "description": "Whether the lock should run the motor until it hits resistance.", @@ -626,5 +646,13 @@ }, "name": "Set a value (advanced)" } + }, + "selector": { + "network_type": { + "options": { + "new": "It's new", + "existing": "It already exists" + } + } } } diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 2ff80d8505e..75e6b31bc50 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY from zwave_js_server.const.command_class.barrier_operator import ( BarrierEventSignalingSubsystemState, @@ -12,26 +11,26 @@ from zwave_js_server.const.command_class.barrier_operator import ( from zwave_js_server.model.driver import Driver from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_switch(info: ZwaveDiscoveryInfo) -> None: @@ -65,7 +64,7 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): """Representation of a Z-Wave switch.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the switch.""" super().__init__(config_entry, driver, info) @@ -95,7 +94,7 @@ class ZWaveIndicatorSwitch(ZWaveSwitch): """Representation of a Z-Wave Indicator CC switch.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the switch.""" super().__init__(config_entry, driver, info) @@ -108,7 +107,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -164,7 +163,7 @@ class ZWaveConfigParameterSwitch(ZWaveSwitch): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterSwitch entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index e934faec70c..d25737ffd59 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -8,8 +8,8 @@ from homeassistant.helpers.trigger import Trigger from .triggers import event, value_updated TRIGGERS = { - event.PLATFORM_TYPE: event.EventTrigger, - value_updated.PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, + event.RELATIVE_PLATFORM_TYPE: event.EventTrigger, + value_updated.RELATIVE_PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, } diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index f74357327e9..150a32113e6 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -5,14 +5,18 @@ from __future__ import annotations from collections.abc import Callable import functools -from pydantic.v1 import ValidationError +from pydantic import ValidationError import voluptuous as vol -from zwave_js_server.client import Client from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_PLATFORM, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,13 +24,11 @@ from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInf from homeassistant.helpers.typing import ConfigType from ..const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_EVENT, ATTR_EVENT_DATA, ATTR_EVENT_SOURCE, ATTR_NODE_ID, ATTR_PARTIAL_DICT_MATCH, - DATA_CLIENT, DOMAIN, ) from ..helpers import ( @@ -36,8 +38,11 @@ from ..helpers import ( ) from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" def validate_non_node_event_source(obj: dict) -> dict: @@ -80,7 +85,7 @@ def validate_event_data(obj: dict) -> dict: except ValidationError as exc: # Filter out required field errors if keys can be missing, and if there are # still errors, raise an exception - if [error for error in exc.errors() if error["type"] != "value_error.missing"]: + if [error for error in exc.errors() if error["type"] != "missing"]: raise vol.MultipleInvalid from exc return obj @@ -219,7 +224,7 @@ async def async_attach_trigger( entry_id = config[ATTR_CONFIG_ENTRY_ID] entry = hass.config_entries.async_get_entry(entry_id) assert entry - client: Client = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client driver = client.driver assert driver drivers.add(driver) @@ -262,13 +267,13 @@ class EventTrigger(Trigger): self._hass = hass @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return await async_validate_trigger_config(hass, config) - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py index 1ef9ebaae28..03792771bd3 100644 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ b/homeassistant/components/zwave_js/triggers/trigger_helpers.py @@ -1,14 +1,12 @@ """Helpers for Z-Wave JS custom triggers.""" -from zwave_js_server.client import Client as ZwaveClient - from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType -from ..const import ATTR_CONFIG_ENTRY_ID, DATA_CLIENT, DOMAIN +from ..const import DOMAIN @callback @@ -37,7 +35,7 @@ def async_bypass_dynamic_config_validation( return True # The driver may not be ready when the config entry is loaded. - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client if client.driver is None: return True diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index a50053fa2db..f46592769cb 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -37,8 +37,11 @@ from ..const import ( from ..helpers import async_get_nodes_from_targets, get_device_id from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" ATTR_FROM = "from" ATTR_TO = "to" @@ -213,13 +216,13 @@ class ValueUpdatedTrigger(Trigger): self._hass = hass @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return await async_validate_trigger_config(hass, config) - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 985c4a86813..9e9d6ee2ef3 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -4,30 +4,29 @@ from __future__ import annotations import asyncio from collections import Counter -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Final +from typing import Any, Final, cast from awesomeversion import AwesomeVersion -from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand from zwave_js_server.model.driver import Driver -from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.node.firmware import ( - NodeFirmwareUpdateInfo, - NodeFirmwareUpdateProgress, - NodeFirmwareUpdateResult, +from zwave_js_server.model.firmware import ( + FirmwareUpdateInfo, + FirmwareUpdateProgress, + FirmwareUpdateResult, ) +from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.node.firmware import NodeFirmwareUpdateInfo from homeassistant.components.update import ( ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, + UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -36,21 +35,65 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData -from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER +from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DOMAIN, LOGGER from .helpers import get_device_info, get_valueless_base_unique_id +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 1 UPDATE_DELAY_STRING = "delay" -UPDATE_DELAY_INTERVAL = 5 # In minutes +UPDATE_DELAY_INTERVAL = 15 # In seconds ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware" +@dataclass(frozen=True, kw_only=True) +class ZWaveUpdateEntityDescription(UpdateEntityDescription): + """Class describing Z-Wave update entity.""" + + install_method: Callable[ + [ZWaveFirmwareUpdateEntity, FirmwareUpdateInfo], + Awaitable[FirmwareUpdateResult], + ] + progress_method: Callable[[ZWaveFirmwareUpdateEntity], Callable[[], None]] + finished_method: Callable[[ZWaveFirmwareUpdateEntity], Callable[[], None]] + + +CONTROLLER_UPDATE_ENTITY_DESCRIPTION = ZWaveUpdateEntityDescription( + key="controller_firmware_update", + install_method=( + lambda entity, firmware_update_info: entity.driver.async_firmware_update_otw( + update_info=firmware_update_info + ) + ), + progress_method=lambda entity: entity.driver.on( + "firmware update progress", entity.update_progress + ), + finished_method=lambda entity: entity.driver.on( + "firmware update finished", entity.update_finished + ), +) +NODE_UPDATE_ENTITY_DESCRIPTION = ZWaveUpdateEntityDescription( + key="node_firmware_update", + install_method=( + lambda entity, + firmware_update_info: entity.driver.controller.async_firmware_update_ota( + entity.node, cast(NodeFirmwareUpdateInfo, firmware_update_info) + ) + ), + progress_method=lambda entity: entity.node.on( + "firmware update progress", entity.update_progress + ), + finished_method=lambda entity: entity.node.on( + "firmware update finished", entity.update_finished + ), +) + + @dataclass -class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): +class ZWaveFirmwareUpdateExtraStoredData(ExtraStoredData): """Extra stored data for Z-Wave node firmware update entity.""" - latest_version_firmware: NodeFirmwareUpdateInfo | None + latest_version_firmware: FirmwareUpdateInfo | None def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" @@ -61,7 +104,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): } @classmethod - def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: + def from_dict(cls, data: dict[str, Any]) -> ZWaveFirmwareUpdateExtraStoredData: """Initialize the extra data from a dict.""" # If there was no firmware info stored, or if it's stale info, we don't restore # anything. @@ -71,29 +114,45 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): ): return cls(None) - return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) + return cls(FirmwareUpdateInfo.from_dict(firmware_dict)) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave update entity from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client cnt: Counter = Counter() @callback def async_add_firmware_update_entity(node: ZwaveNode) -> None: """Add firmware update entity.""" - # We need to delay the first update of each entity to avoid flooding the network - # so we maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL - # minute increments. + # Delay the first update of each entity to avoid spamming the firmware server. + # Maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL + # second increments. cnt[UPDATE_DELAY_STRING] += 1 - delay = timedelta(minutes=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) + delay = timedelta(seconds=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, delay)]) + if node.is_controller_node: + # If the node is a controller, we create a controller firmware update entity + entity = ZWaveFirmwareUpdateEntity( + driver, + node, + delay=delay, + entity_description=CONTROLLER_UPDATE_ENTITY_DESCRIPTION, + ) + else: + # If the node is not a controller, we create a node firmware update entity + entity = ZWaveFirmwareUpdateEntity( + driver, + node, + delay=delay, + entity_description=NODE_UPDATE_ENTITY_DESCRIPTION, + ) + async_add_entities([entity]) config_entry.async_on_unload( async_dispatcher_connect( @@ -104,9 +163,12 @@ async def async_setup_entry( ) -class ZWaveNodeFirmwareUpdate(UpdateEntity): +class ZWaveFirmwareUpdateEntity(UpdateEntity): """Representation of a firmware update entity.""" + driver: Driver + entity_description: ZWaveUpdateEntityDescription + node: ZwaveNode _attr_entity_category = EntityCategory.CONFIG _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_supported_features = ( @@ -117,17 +179,23 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, driver: Driver, node: ZwaveNode, delay: timedelta) -> None: + def __init__( + self, + driver: Driver, + node: ZwaveNode, + delay: timedelta, + entity_description: ZWaveUpdateEntityDescription, + ) -> None: """Initialize a Z-Wave device firmware update entity.""" self.driver = driver + self.entity_description = entity_description self.node = node - self._latest_version_firmware: NodeFirmwareUpdateInfo | None = None - self._status_unsub: Callable[[], None] | None = None + self._latest_version_firmware: FirmwareUpdateInfo | None = None self._poll_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None self._finished_unsub: Callable[[], None] | None = None self._finished_event = asyncio.Event() - self._result: NodeFirmwareUpdateResult | None = None + self._result: FirmwareUpdateResult | None = None self._delay: Final[timedelta] = delay # Entity class attributes @@ -139,20 +207,14 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._attr_device_info = get_device_info(driver, node) @property - def extra_restore_state_data(self) -> ZWaveNodeFirmwareUpdateExtraStoredData: + def extra_restore_state_data(self) -> ZWaveFirmwareUpdateExtraStoredData: """Return ZWave Node Firmware Update specific state data to be restored.""" - return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware) + return ZWaveFirmwareUpdateExtraStoredData(self._latest_version_firmware) @callback - def _update_on_status_change(self, _: dict[str, Any]) -> None: - """Update the entity when node is awake.""" - self._status_unsub = None - self.hass.async_create_task(self._async_update()) - - @callback - def _update_progress(self, event: dict[str, Any]) -> None: + def update_progress(self, event: dict[str, Any]) -> None: """Update install progress on event.""" - progress: NodeFirmwareUpdateProgress = event["firmware_update_progress"] + progress: FirmwareUpdateProgress = event["firmware_update_progress"] if not self._latest_version_firmware: return self._attr_in_progress = True @@ -160,9 +222,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self.async_write_ha_state() @callback - def _update_finished(self, event: dict[str, Any]) -> None: + def update_finished(self, event: dict[str, Any]) -> None: """Update install progress on event.""" - result: NodeFirmwareUpdateResult = event["firmware_update_finished"] + result: FirmwareUpdateResult = event["firmware_update_finished"] self._result = result self._finished_event.set() @@ -200,19 +262,6 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) return - # If device is asleep/dead, wait for it to wake up/become alive before - # attempting an update - for status, event_name in ( - (NodeStatus.ASLEEP, "wake up"), - (NodeStatus.DEAD, "alive"), - ): - if self.node.status == status: - if not self._status_unsub: - self._status_unsub = self.node.once( - event_name, self._update_on_status_change - ) - return - try: # Retrieve all firmware updates including non-stable ones but filter # non-stable channels out @@ -272,15 +321,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._attr_update_percentage = None self.async_write_ha_state() - self._progress_unsub = self.node.on( - "firmware update progress", self._update_progress - ) - self._finished_unsub = self.node.on( - "firmware update finished", self._update_finished - ) + self._progress_unsub = self.entity_description.progress_method(self) + self._finished_unsub = self.entity_description.finished_method(self) try: - await self.driver.controller.async_firmware_update_ota(self.node, firmware) + await self.entity_description.install_method(self, firmware) except BaseZwaveJSServerError as err: self._unsub_firmware_events_and_reset_progress() raise HomeAssistantError(err) from err @@ -348,8 +393,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): is not None and (extra_data := await self.async_get_last_extra_data()) and ( - latest_version_firmware - := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + latest_version_firmware := ZWaveFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) @@ -369,17 +413,14 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ): self._attr_latest_version = self._attr_installed_version - # Spread updates out in 5 minute increments to avoid flooding the network + # Spread updates out in 15 second increments + # to avoid spamming the firmware server self.async_on_remove( async_call_later(self.hass, self._delay, self._async_update) ) async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed.""" - if self._status_unsub: - self._status_unsub() - self._status_unsub = None - if self._poll_unsub: self._poll_unsub() self._poll_unsub = None diff --git a/homeassistant/config.py b/homeassistant/config.py index ca1c87e4a11..e77e5c32f40 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -13,7 +13,6 @@ import logging import operator import os from pathlib import Path -import re import shutil from types import ModuleType from typing import TYPE_CHECKING, Any @@ -39,8 +38,6 @@ from .util.yaml.objects import NodeStrClass _LOGGER = logging.getLogger(__name__) -RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") -RE_ASCII = re.compile(r"\033\[[^m]*m") YAML_CONFIG_FILE = "configuration.yaml" VERSION_FILE = ".HA_VERSION" CONFIG_DIR_NAME = ".homeassistant" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c2481ae3fa3..f5ccf9c3143 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -298,8 +298,10 @@ class ConfigFlowContext(FlowContext, total=False): class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): """Typed result dict for config flow.""" + # Extra keys, only present if type is CREATE_ENTRY minor_version: int options: Mapping[str, Any] + result: ConfigEntry subentries: Iterable[ConfigSubentryData] version: int @@ -1646,6 +1648,7 @@ class ConfigEntriesFlowManager( report_usage( "creates a config entry when another entry with the same unique ID " "exists", + breaks_in_ha_version="2026.3", core_behavior=ReportBehavior.LOG, core_integration_behavior=ReportBehavior.LOG, custom_integration_behavior=ReportBehavior.LOG, @@ -3344,7 +3347,6 @@ class ConfigSubentryFlowManager( ), ) - result["result"] = True return result @@ -3383,6 +3385,34 @@ class ConfigSubentryFlow( return result + @callback + def _async_update( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + ) -> bool: + """Update config subentry and return result. + + Internal to be used by update_and_abort and update_reload_and_abort methods only. + """ + + if data_updates is not UNDEFINED: + if data is not UNDEFINED: + raise ValueError("Cannot set both data and data_updates") + data = subentry.data | data_updates + return self.hass.config_entries.async_update_subentry( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + ) + @callback def async_update_and_abort( self, @@ -3402,24 +3432,62 @@ class ConfigSubentryFlow( :param title: replace the title of the subentry :param unique_id: replace the unique_id of the subentry """ - if data_updates is not UNDEFINED: - if data is not UNDEFINED: - raise ValueError("Cannot set both data and data_updates") - data = subentry.data | data_updates - self.hass.config_entries.async_update_subentry( + self._async_update( entry=entry, subentry=subentry, unique_id=unique_id, title=title, data=data, + data_updates=data_updates, ) return self.async_abort(reason="reconfigure_successful") + @callback + def async_update_reload_and_abort( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + reload_even_if_entry_is_unchanged: bool = True, + ) -> SubentryFlowResult: + """Update config subentry, reload config entry and finish subentry flow. + + :param data: replace the subentry data with new data + :param data_updates: add items from data_updates to subentry data - existing + keys are overridden + :param title: replace the title of the subentry + :param unique_id: replace the unique_id of the subentry + :param reload_even_if_entry_is_unchanged: set this to `False` if the entry + should not be reloaded if it is unchanged + """ + result = self._async_update( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + data_updates=data_updates, + ) + if reload_even_if_entry_is_unchanged or result: + if entry.update_listeners: + raise ValueError("Cannot update and reload entry with update listeners") + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + @property def _entry_id(self) -> str: """Return config entry id.""" return self.handler[0] + @property + def _subentry_type(self) -> str: + """Return type of subentry we are editing/creating.""" + return self.handler[1] + @callback def _get_entry(self) -> ConfigEntry: """Return the config entry linked to the current context.""" @@ -3485,9 +3553,23 @@ class OptionsFlowManager( entry = self.hass.config_entries.async_get_known_entry(flow.handler) if result["data"] is not None: - self.hass.config_entries.async_update_entry(entry, options=result["data"]) + automatic_reload = False + if isinstance(flow, OptionsFlowWithReload): + automatic_reload = flow.automatic_reload + + if automatic_reload and entry.update_listeners: + raise ValueError( + "Config entry update listeners should not be used with OptionsFlowWithReload" + ) + + if ( + self.hass.config_entries.async_update_entry( + entry, options=result["data"] + ) + and automatic_reload is True + ): + self.hass.config_entries.async_schedule_reload(entry.entry_id) - result["result"] = True return result async def _async_setup_preview( @@ -3594,6 +3676,18 @@ class OptionsFlowWithConfigEntry(OptionsFlow): return self._options +class OptionsFlowWithReload(OptionsFlow): + """Automatic reloading class for config options flows. + + Triggers an automatic reload of the config entry when the flow ends with + calling `async_create_entry` with changed options. + It's not allowed to use this class if the integration uses config entry + update listeners. + """ + + automatic_reload: bool = True + + class EntityRegistryDisabledHandler: """Handler when entities related to config entries updated disabled_by.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index 0abdcd59b77..5ec3fb56903 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 7 +MINOR_VERSION: Final = 9 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -245,6 +245,7 @@ CONF_PLATFORM: Final = "platform" CONF_PORT: Final = "port" CONF_PREFIX: Final = "prefix" CONF_PROFILE_NAME: Final = "profile_name" +CONF_PROMPT: Final = "prompt" CONF_PROTOCOL: Final = "protocol" CONF_PROXY_SSL: Final = "proxy_ssl" CONF_QUOTE: Final = "quote" @@ -468,6 +469,9 @@ ATTR_NAME: Final = "name" # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID: Final = "entity_id" +# Contains one string, the config entry ID +ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" + # Contains one string or a list of strings, each being an area id ATTR_AREA_ID: Final = "area_id" @@ -584,6 +588,7 @@ ATTR_PERSONS: Final = "persons" class UnitOfApparentPower(StrEnum): """Apparent power units.""" + MILLIVOLT_AMPERE = "mVA" VOLT_AMPERE = "VA" @@ -604,6 +609,7 @@ class UnitOfPower(StrEnum): class UnitOfReactivePower(StrEnum): """Reactive power units.""" + MILLIVOLT_AMPERE_REACTIVE = "mvar" VOLT_AMPERE_REACTIVE = "var" KILO_VOLT_AMPERE_REACTIVE = "kvar" @@ -665,7 +671,7 @@ class UnitOfElectricCurrent(StrEnum): class UnitOfElectricPotential(StrEnum): """Electric potential units.""" - MICROVOLT = "µV" + MICROVOLT = "μV" MILLIVOLT = "mV" VOLT = "V" KILOVOLT = "kV" @@ -815,7 +821,7 @@ class UnitOfMass(StrEnum): GRAMS = "g" KILOGRAMS = "kg" MILLIGRAMS = "mg" - MICROGRAMS = "µg" + MICROGRAMS = "μg" OUNCES = "oz" POUNDS = "lb" STONES = "st" @@ -833,13 +839,13 @@ class UnitOfConductivity( """Conductivity units.""" SIEMENS_PER_CM = "S/cm" - MICROSIEMENS_PER_CM = "µS/cm" + MICROSIEMENS_PER_CM = "μS/cm" MILLISIEMENS_PER_CM = "mS/cm" # Deprecated aliases SIEMENS = "S/cm" """Deprecated: Please use UnitOfConductivity.SIEMENS_PER_CM""" - MICROSIEMENS = "µS/cm" + MICROSIEMENS = "μS/cm" """Deprecated: Please use UnitOfConductivity.MICROSIEMENS_PER_CM""" MILLISIEMENS = "mS/cm" """Deprecated: Please use UnitOfConductivity.MILLISIEMENS_PER_CM""" @@ -910,8 +916,9 @@ class UnitOfPrecipitationDepth(StrEnum): # Concentration units -CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" +CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" +CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³" CONCENTRATION_PARTS_PER_CUBIC_METER: Final = "p/m³" CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" diff --git a/homeassistant/core.py b/homeassistant/core.py index c5d4ca79371..299a7d32306 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -157,7 +157,6 @@ class EventStateEventData(TypedDict): """Base class for EVENT_STATE_CHANGED and EVENT_STATE_REPORTED data.""" entity_id: str - new_state: State | None class EventStateChangedData(EventStateEventData): @@ -166,6 +165,7 @@ class EventStateChangedData(EventStateEventData): A state changed event is fired when on state write the state is changed. """ + new_state: State | None old_state: State | None @@ -175,6 +175,8 @@ class EventStateReportedData(EventStateEventData): A state reported event is fired when on state write the state is unchanged. """ + last_reported: datetime.datetime + new_state: State old_last_reported: datetime.datetime @@ -384,7 +386,7 @@ def get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: while isinstance(check_target, functools.partial): check_target = check_target.func - if asyncio.iscoroutinefunction(check_target): + if inspect.iscoroutinefunction(check_target): return HassJobType.Coroutinefunction if is_callback(check_target): return HassJobType.Callback @@ -532,7 +534,7 @@ class HomeAssistant: This method is a coroutine. """ - _LOGGER.info("Starting Home Assistant") + _LOGGER.info("Starting Home Assistant %s", __version__) self.set_state(CoreState.starting) self.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE) @@ -1749,18 +1751,38 @@ class CompressedState(TypedDict): class State: - """Object to represent a state within the state machine. + """Object to represent a state within the state machine.""" - entity_id: the entity that is represented. - state: the state of the entity - attributes: extra information on entity and state - last_changed: last time the state was changed. - last_reported: last time the state was reported. - last_updated: last time the state or attributes were changed. - context: Context in which it was created - domain: Domain of this state. - object_id: Object id of this state. + entity_id: str + """The entity that is represented by the state.""" + domain: str + """Domain of the entity that is represented by the state.""" + object_id: str + """object_id: Object id of this state.""" + state: str + """The state of the entity.""" + attributes: ReadOnlyDict[str, Any] + """Extra information on entity and state""" + last_changed: datetime.datetime + """Last time the state was changed.""" + last_reported: datetime.datetime + """Last time the state was reported. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported attribute of the old + state will not be modified and can safely be used. The last_reported attribute + of the new state may be modified and the last_updated attribute should be used + instead. + + When handling a state report event, the last_reported attribute may be + modified and last_reported from the event data should be used instead. """ + last_updated: datetime.datetime + """Last time the state or attributes were changed.""" + context: Context + """Context in which the state was created.""" __slots__ = ( "_cache", @@ -1841,7 +1863,20 @@ class State: @under_cached_property def last_reported_timestamp(self) -> float: - """Timestamp of last report.""" + """Timestamp of last report. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported_timestamp attribute + of the old state will not be modified and can safely be used. The + last_reported_timestamp attribute of the new state may be modified and the + last_updated_timestamp attribute should be used instead. + + When handling a state report event, the last_reported_timestamp attribute may + be modified and last_reported from the event data should be used instead. + """ + return self.last_reported.timestamp() @under_cached_property @@ -2340,6 +2375,7 @@ class StateMachine: EVENT_STATE_REPORTED, { "entity_id": entity_id, + "last_reported": now, "old_last_reported": old_last_reported, "new_state": old_state, }, diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index ce1c0806b14..5023d291ad5 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -142,7 +142,6 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False): progress_task: asyncio.Task[Any] | None reason: str required: bool - result: Any step_id: str title: str translation_domain: str @@ -677,9 +676,10 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): and key in suggested_values ): new_section_key = copy.copy(key) - schema[new_section_key] = val - val.schema = self.add_suggested_values_to_schema( - val.schema, suggested_values[key] + new_val = copy.copy(val) + schema[new_section_key] = new_val + new_val.schema = self.add_suggested_values_to_schema( + new_val.schema, suggested_values[key] ) continue @@ -706,10 +706,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): last_step: bool | None = None, preview: str | None = None, ) -> _FlowResultT: - """Return the definition of a form to gather user input. - - The step_id parameter is deprecated and will be removed in a future release. - """ + """Return the definition of a form to gather user input.""" flow_result = self._flow_result( type=FlowResultType.FORM, flow_id=self.flow_id, @@ -771,10 +768,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): url: str, description_placeholders: Mapping[str, str] | None = None, ) -> _FlowResultT: - """Return the definition of an external step for the user to take. - - The step_id parameter is deprecated and will be removed in a future release. - """ + """Return the definition of an external step for the user to take.""" flow_result = self._flow_result( type=FlowResultType.EXTERNAL_STEP, flow_id=self.flow_id, @@ -805,10 +799,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): description_placeholders: Mapping[str, str] | None = None, progress_task: asyncio.Task[Any] | None = None, ) -> _FlowResultT: - """Show a progress message to the user, without user input allowed. - - The step_id parameter is deprecated and will be removed in a future release. - """ + """Show a progress message to the user, without user input allowed.""" if progress_task is None and not self.__no_progress_task_reported: self.__no_progress_task_reported = True cls = self.__class__ @@ -868,7 +859,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): """Show a navigation menu to the user. Options dict maps step_id => i18n label - The step_id parameter is deprecated and will be removed in a future release. """ flow_result = self._flow_result( type=FlowResultType.MENU, diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 2f088716f8c..0abd4365feb 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -35,6 +35,7 @@ APPLICATION_CREDENTIALS = [ "spotify", "tesla_fleet", "twitch", + "volvo", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index f5303f09302..fcaa824ff39 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -386,6 +386,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "Ink@IAM-T1", }, + { + "connectable": True, + "domain": "inkbird", + "local_name": "Ink@IAM-T2", + }, { "connectable": True, "domain": "inkbird", @@ -396,6 +401,16 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ ], "manufacturer_id": 12628, }, + { + "connectable": False, + "domain": "inkbird", + "manufacturer_data_start": [ + 0, + 98, + 0, + ], + "manufacturer_id": 12884, + }, { "connectable": True, "domain": "iron_os", @@ -819,6 +834,12 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ ], "manufacturer_id": 76, }, + { + "connectable": True, + "domain": "togrill", + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "xiaomi_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 97e7929d317..19fb5491465 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -37,6 +37,7 @@ FLOWS = { "airgradient", "airly", "airnow", + "airos", "airq", "airthings", "airthings_ble", @@ -124,6 +125,7 @@ FLOWS = { "cpuspeed", "crownstone", "daikin", + "datadog", "deako", "deconz", "deluge", @@ -346,7 +348,6 @@ FLOWS = { "lg_thinq", "lidarr", "lifx", - "linear_garage_door", "linkplay", "litejet", "litterrobot", @@ -449,6 +450,7 @@ FLOWS = { "onkyo", "onvif", "open_meteo", + "open_router", "openai_conversation", "openexchangerates", "opengarage", @@ -574,6 +576,7 @@ FLOWS = { "sky_remote", "skybell", "slack", + "sleep_as_android", "sleepiq", "slide_local", "slimproto", @@ -651,6 +654,7 @@ FLOWS = { "tilt_pi", "time_date", "todoist", + "togrill", "tolo", "tomorrowio", "toon", @@ -680,6 +684,7 @@ FLOWS = { "upcloud", "upnp", "uptime", + "uptime_kuma", "uptimerobot", "v2c", "vallox", @@ -698,6 +703,7 @@ FLOWS = { "vodafone_station", "voip", "volumio", + "volvo", "volvooncall", "vulcan", "wake_on_lan", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b253c5a553d..3c1d929b1d8 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -288,7 +288,7 @@ DHCP: Final[list[dict[str, str | bool]]] = [ }, { "domain": "home_connect", - "hostname": "(siemens|neff)-*", + "hostname": "(bosch|neff|siemens)-*", "macaddress": "38B4D3*", }, { @@ -539,6 +539,26 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "playstation_network", "macaddress": "D44B5E*", }, + { + "domain": "playstation_network", + "macaddress": "F8D0AC*", + }, + { + "domain": "playstation_network", + "macaddress": "E86E3A*", + }, + { + "domain": "playstation_network", + "macaddress": "FC0FE6*", + }, + { + "domain": "playstation_network", + "macaddress": "9C37CB*", + }, + { + "domain": "playstation_network", + "macaddress": "84E657*", + }, { "domain": "powerwall", "hostname": "1118431-*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bd88338c4b9..10f5ea45427 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -665,6 +665,11 @@ "config_flow": true, "iot_class": "local_push" }, + "bauknecht": { + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" + }, "bbox": { "name": "Bbox", "integration_type": "hub", @@ -1166,7 +1171,7 @@ "datadog": { "name": "Datadog", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "ddwrt": { @@ -1483,12 +1488,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "dweet": { - "name": "dweet.io", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "eafm": { "name": "Environment Agency Flood Gauges", "integration_type": "hub", @@ -2138,6 +2137,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "frient": { + "name": "Frient", + "iot_standards": [ + "zigbee" + ] + }, "fritzbox": { "name": "FRITZ!Box", "integrations": { @@ -3165,8 +3170,7 @@ "name": "Jellyfin", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "single_config_entry": true + "iot_class": "local_polling" }, "jewish_calendar": { "name": "Jewish Calendar", @@ -3259,7 +3263,7 @@ }, "keymitt_ble": { "name": "Keymitt MicroBot Push", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "assumed_state" }, @@ -3508,12 +3512,6 @@ "integration_type": "virtual", "supported_by": "idasen_desk" }, - "linear_garage_door": { - "name": "Linear Garage Door", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "linkedgo": { "name": "LinkedGo", "integration_type": "virtual", @@ -3835,11 +3833,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "mercury_nz": { - "name": "Mercury NZ Limited", - "integration_type": "virtual", - "supported_by": "opower" - }, "message_bird": { "name": "MessageBird", "integration_type": "hub", @@ -4628,8 +4621,14 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "open_router": { + "name": "OpenRouter", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "openai_conversation": { - "name": "OpenAI Conversation", + "name": "OpenAI", "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" @@ -5995,6 +5994,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "sleep_as_android": { + "name": "Sleep as Android", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "sleepiq": { "name": "SleepIQ", "integration_type": "hub", @@ -6718,6 +6723,7 @@ "third_reality": { "name": "Third Reality", "iot_standards": [ + "matter", "zigbee" ] }, @@ -6785,6 +6791,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "togrill": { + "name": "ToGrill", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "tolo": { "name": "TOLO Sauna", "integration_type": "hub", @@ -6998,6 +7010,12 @@ "ubiquiti": { "name": "Ubiquiti", "integrations": { + "airos": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Ubiquiti airOS" + }, "unifi": { "integration_type": "hub", "config_flow": true, @@ -7087,6 +7105,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "uptime_kuma": { + "name": "Uptime Kuma", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "uptimerobot": { "name": "UptimeRobot", "integration_type": "hub", @@ -7257,6 +7281,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "volvo": { + "name": "Volvo", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "volvooncall": { "name": "Volvo On Call", "integration_type": "hub", @@ -7650,6 +7680,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "zbox_hub": { + "name": "Z-Box Hub", + "integration_type": "virtual", + "supported_by": "fibaro" + }, "zengge": { "name": "Zengge", "integration_type": "hub", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 18623926ce2..dee0367de24 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -143,6 +143,12 @@ USB = [ "pid": "EA60", "vid": "10C4", }, + { + "description": "*sonoff*lite*mg21*", + "domain": "zha", + "pid": "EA60", + "vid": "10C4", + }, { "domain": "zwave_js", "pid": "0200", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3af4b8caa8d..742840fa849 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -534,6 +534,11 @@ ZEROCONF = { "domain": "homekit_controller", }, ], + "_heos-audio._tcp.local.": [ + { + "domain": "heos", + }, + ], "_homeconnect._tcp.local.": [ { "domain": "home_connect", @@ -563,6 +568,10 @@ ZEROCONF = { "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "bsblan", + "name": "bsb-lan*", + }, { "domain": "eheimdigital", "name": "eheimdigital._http._tcp.local.", @@ -771,6 +780,16 @@ ZEROCONF = { "domain": "onewire", }, ], + "_philipstv_rpc._tcp.local.": [ + { + "domain": "philips_js", + }, + ], + "_philipstv_s_rpc._tcp.local.": [ + { + "domain": "philips_js", + }, + ], "_plexmediasvr._tcp.local.": [ { "domain": "plex", @@ -871,6 +890,10 @@ ZEROCONF = { }, ], "_ssh._tcp.local.": [ + { + "domain": "homee", + "name": "homee-*", + }, { "domain": "smappee", "name": "smappee1*", diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py new file mode 100644 index 00000000000..52a0fc13255 --- /dev/null +++ b/homeassistant/helpers/automation.py @@ -0,0 +1,21 @@ +"""Helpers for automation.""" + + +def get_absolute_description_key(domain: str, key: str) -> str: + """Return the absolute description key.""" + if not key.startswith("_"): + return f"{domain}.{key}" + key = key[1:] # Remove leading underscore + if not key: + return domain + return key + + +def get_relative_description_key(domain: str, key: str) -> str: + """Return the relative description key.""" + platform, *subtype = key.split(".", 1) + if platform != domain: + return f"_{key}" + if not subtype: + return "_" + return subtype[0] diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py deleted file mode 100644 index e445bef4aae..00000000000 --- a/homeassistant/helpers/backup.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Helpers for the backup integration.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.hass_dict import HassKey - -if TYPE_CHECKING: - from homeassistant.components.backup import ( - BackupManager, - BackupPlatformEvent, - ManagerStateEvent, - ) - -DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") -DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") - - -@dataclass(slots=True) -class BackupData: - """Backup data stored in hass.data.""" - - backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( - default_factory=list - ) - backup_platform_event_subscriptions: list[Callable[[BackupPlatformEvent], None]] = ( - field(default_factory=list) - ) - manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) - - -@callback -def async_initialize_backup(hass: HomeAssistant) -> None: - """Initialize backup data. - - This creates the BackupData instance stored in hass.data[DATA_BACKUP] and - registers the basic backup websocket API which is used by frontend to subscribe - to backup events. - """ - from homeassistant.components.backup import basic_websocket # noqa: PLC0415 - - hass.data[DATA_BACKUP] = BackupData() - basic_websocket.async_register_websocket_handlers(hass) - - -async def async_get_manager(hass: HomeAssistant) -> BackupManager: - """Get the backup manager instance. - - Raises HomeAssistantError if the backup integration is not available. - """ - if DATA_BACKUP not in hass.data: - raise HomeAssistantError("Backup integration is not available") - - await hass.data[DATA_BACKUP].manager_ready - return hass.data[DATA_MANAGER] - - -@callback -def async_subscribe_events( - hass: HomeAssistant, - on_event: Callable[[ManagerStateEvent], None], -) -> Callable[[], None]: - """Subscribe to backup events.""" - backup_event_subscriptions = hass.data[DATA_BACKUP].backup_event_subscriptions - - def remove_subscription() -> None: - backup_event_subscriptions.remove(on_event) - - backup_event_subscriptions.append(on_event) - return remove_subscription - - -@callback -def async_subscribe_platform_events( - hass: HomeAssistant, - on_event: Callable[[BackupPlatformEvent], None], -) -> Callable[[], None]: - """Subscribe to backup platform events.""" - backup_platform_event_subscriptions = hass.data[ - DATA_BACKUP - ].backup_platform_event_subscriptions - - def remove_subscription() -> None: - backup_platform_event_subscriptions.remove(on_event) - - backup_platform_event_subscriptions.append(on_event) - return remove_subscription diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index fbdf2dce7b1..d9f16217c2e 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -2,26 +2,22 @@ from __future__ import annotations -import asyncio +import abc from collections import deque -from collections.abc import Callable, Container, Generator +from collections.abc import Callable, Container, Coroutine, Generator, Iterable from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft +import inspect import logging import re import sys -from typing import Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, cast import voluptuous as vol -from homeassistant.components import zone as zone_cmp -from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_ABOVE, CONF_AFTER, CONF_ATTRIBUTE, @@ -37,7 +33,6 @@ from homeassistant.const import ( CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WEEKDAY, - CONF_ZONE, ENTITY_MATCH_ALL, ENTITY_MATCH_ANY, STATE_UNAVAILABLE, @@ -53,11 +48,20 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) -from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_integration, + async_get_integrations, +) from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.hass_dict import HassKey +from homeassistant.util.yaml import load_yaml_dict from . import config_validation as cv, entity_registry as er +from .automation import get_absolute_description_key, get_relative_description_key +from .integration_platform import async_process_integration_platforms from .template import Template, render_complex from .trace import ( TraceElement, @@ -75,7 +79,9 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" -_PLATFORM_ALIASES = { +_LOGGER = logging.getLogger(__name__) + +_PLATFORM_ALIASES: dict[str | None, str | None] = { "and": None, "device": "device_automation", "not": None, @@ -85,7 +91,6 @@ _PLATFORM_ALIASES = { "template": None, "time": None, "trigger": None, - "zone": None, } INPUT_ENTITY_ID = re.compile( @@ -93,18 +98,127 @@ INPUT_ENTITY_ID = re.compile( ) -class ConditionProtocol(Protocol): - """Define the format of condition modules.""" +CONDITION_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey( + "condition_description_cache" +) +CONDITION_PLATFORM_SUBSCRIPTIONS: HassKey[ + list[Callable[[set[str]], Coroutine[Any, Any, None]]] +] = HassKey("condition_platform_subscriptions") +CONDITIONS: HassKey[dict[str, str]] = HassKey("conditions") - async def async_validate_condition_config( - self, hass: HomeAssistant, config: ConfigType + +# Basic schemas to sanity check the condition descriptions, +# full validation is done by hassfest.conditions +_FIELD_SCHEMA = vol.Schema( + {}, + extra=vol.ALLOW_EXTRA, +) + +_CONDITION_SCHEMA = vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + + +def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise vol.Invalid("Key does not start with .") + return key + + +_CONDITIONS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, starts_with_dot)): object, + cv.underscore_slug: vol.Any(None, _CONDITION_SCHEMA), + } +) + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the condition helper.""" + hass.data[CONDITION_DESCRIPTION_CACHE] = {} + hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = [] + hass.data[CONDITIONS] = {} + await async_process_integration_platforms( + hass, "condition", _register_condition_platform, wait_for_platforms=True + ) + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[set[str]], Coroutine[Any, Any, None]], +) -> Callable[[], None]: + """Subscribe to condition platform events.""" + condition_platform_event_subscriptions = hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] + + def remove_subscription() -> None: + condition_platform_event_subscriptions.remove(on_event) + + condition_platform_event_subscriptions.append(on_event) + return remove_subscription + + +async def _register_condition_platform( + hass: HomeAssistant, integration_domain: str, platform: ConditionProtocol +) -> None: + """Register a condition platform.""" + + new_conditions: set[str] = set() + + if hasattr(platform, "async_get_conditions"): + for condition_key in await platform.async_get_conditions(hass): + condition_key = get_absolute_description_key( + integration_domain, condition_key + ) + hass.data[CONDITIONS][condition_key] = integration_domain + new_conditions.add(condition_key) + else: + _LOGGER.debug( + "Integration %s does not provide condition support, skipping", + integration_domain, + ) + return + + # We don't use gather here because gather adds additional overhead + # when wrapping each coroutine in a task, and we expect our listeners + # to call condition.async_get_all_descriptions which will only yield + # the first time it's called, after that it returns cached data. + for listener in hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]: + try: + await listener(new_conditions) + except Exception: + _LOGGER.exception("Error while notifying condition platform listener") + + +class Condition(abc.ABC): + """Condition class.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + + @classmethod + @abc.abstractmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - def async_condition_from_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConditionCheckerType: - """Evaluate state based on configuration.""" + @abc.abstractmethod + async def async_get_checker(self) -> ConditionCheckerType: + """Get the condition checker.""" + + +class ConditionProtocol(Protocol): + """Define the format of condition modules.""" + + async def async_get_conditions( + self, hass: HomeAssistant + ) -> dict[str, type[Condition]]: + """Return the conditions provided by this integration.""" type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -177,20 +291,21 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke async def _async_get_condition_platform( - hass: HomeAssistant, config: ConfigType -) -> ConditionProtocol | None: - platform = config[CONF_CONDITION] + hass: HomeAssistant, condition_key: str +) -> tuple[str, ConditionProtocol | None]: + platform_and_sub_type = condition_key.split(".") + platform: str | None = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) if platform is None: - return None + return "", None try: integration = await async_get_integration(hass, platform) except IntegrationNotFound: raise HomeAssistantError( - f'Invalid condition "{platform}" specified {config}' + f'Invalid condition "{condition_key}" specified' ) from None try: - return await integration.async_get_platform("condition") + return platform, await integration.async_get_platform("condition") except ImportError: raise HomeAssistantError( f"Integration '{platform}' does not provide condition support" @@ -205,19 +320,6 @@ async def async_from_config( Should be run on the event loop. """ - factory: Any = None - platform = await _async_get_condition_platform(hass, config) - - if platform is None: - condition = config.get(CONF_CONDITION) - for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) - - if factory: - break - else: - factory = platform.async_condition_from_config - # Check if condition is not enabled if CONF_ENABLED in config: enabled = config[CONF_ENABLED] @@ -239,12 +341,30 @@ async def async_from_config( return disabled_condition + condition_key: str = config[CONF_CONDITION] + factory: Any = None + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) + + if platform is not None: + condition_descriptors = await platform.async_get_conditions(hass) + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + condition_instance = condition_descriptors[relative_condition_key](hass, config) + return await condition_instance.async_get_checker() + + for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): + factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) + + if factory: + break + # Check for partials to properly determine if coroutine function check_factory = factory while isinstance(check_factory, ft.partial): check_factory = check_factory.func - if asyncio.iscoroutinefunction(check_factory): + if inspect.iscoroutinefunction(check_factory): return cast(ConditionCheckerType, await factory(hass, config)) return cast(ConditionCheckerType, factory(config)) @@ -699,6 +819,8 @@ def time( for the opposite. "(23:59 <= now < 00:01)" would be the same as "not (00:01 <= now < 23:59)". """ + from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415 + now = dt_util.now() now_time = now.time() @@ -797,99 +919,6 @@ def time_from_config(config: ConfigType) -> ConditionCheckerType: return time_if -def zone( - hass: HomeAssistant, - zone_ent: str | State | None, - entity: str | State | None, -) -> bool: - """Test if zone-condition matches. - - Async friendly. - """ - if zone_ent is None: - raise ConditionErrorMessage("zone", "no zone specified") - - if isinstance(zone_ent, str): - zone_ent_id = zone_ent - - if (zone_ent := hass.states.get(zone_ent)) is None: - raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}") - - if entity is None: - raise ConditionErrorMessage("zone", "no entity specified") - - if isinstance(entity, str): - entity_id = entity - - if (entity := hass.states.get(entity)) is None: - raise ConditionErrorMessage("zone", f"unknown entity {entity_id}") - else: - entity_id = entity.entity_id - - if entity.state in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - return False - - latitude = entity.attributes.get(ATTR_LATITUDE) - longitude = entity.attributes.get(ATTR_LONGITUDE) - - if latitude is None: - raise ConditionErrorMessage( - "zone", f"entity {entity_id} has no 'latitude' attribute" - ) - - if longitude is None: - raise ConditionErrorMessage( - "zone", f"entity {entity_id} has no 'longitude' attribute" - ) - - return zone_cmp.in_zone( - zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0) - ) - - -def zone_from_config(config: ConfigType) -> ConditionCheckerType: - """Wrap action method with zone based condition.""" - entity_ids = config.get(CONF_ENTITY_ID, []) - zone_entity_ids = config.get(CONF_ZONE, []) - - @trace_condition_function - def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Test if condition.""" - errors = [] - - all_ok = True - for entity_id in entity_ids: - entity_ok = False - for zone_entity_id in zone_entity_ids: - try: - if zone(hass, zone_entity_id, entity_id): - entity_ok = True - except ConditionErrorMessage as ex: - errors.append( - ConditionErrorMessage( - "zone", - ( - f"error matching {entity_id} with {zone_entity_id}:" - f" {ex.message}" - ), - ) - ) - - if not entity_ok: - all_ok = False - - # Raise the errors only if no definitive result was found - if errors and not all_ok: - raise ConditionErrorContainer("zone", errors=errors) - - return all_ok - - return if_in_zone - - async def async_trigger_from_config( hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: @@ -936,8 +965,9 @@ async def async_validate_condition_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - condition = config[CONF_CONDITION] - if condition in ("and", "not", "or"): + condition_key: str = config[CONF_CONDITION] + + if condition_key in ("and", "not", "or"): conditions = [] for sub_cond in config["conditions"]: sub_cond = await async_validate_condition_config(hass, sub_cond) @@ -945,13 +975,23 @@ async def async_validate_condition_config( config["conditions"] = conditions return config - platform = await _async_get_condition_platform(hass, config) + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) + if platform is not None: - return await platform.async_validate_condition_config(hass, config) - if platform is None and condition in ("numeric_state", "state"): + condition_descriptors = await platform.async_get_conditions(hass) + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + if not (condition_class := condition_descriptors.get(relative_condition_key)): + raise vol.Invalid(f"Invalid condition '{condition_key}' specified") + return await condition_class.async_validate_config(hass, config) + + if platform is None and condition_key in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], - getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)), + getattr( + sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition_key) + ), ) return validator(hass, config) @@ -1059,3 +1099,112 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]: referenced.add(device_id) return referenced + + +def _load_conditions_file(integration: Integration) -> dict[str, Any]: + """Load conditions file for an integration.""" + try: + return cast( + dict[str, Any], + _CONDITIONS_SCHEMA( + load_yaml_dict(str(integration.file_path / "conditions.yaml")) + ), + ) + except FileNotFoundError: + _LOGGER.warning( + "Unable to find conditions.yaml for the %s integration", integration.domain + ) + return {} + except (HomeAssistantError, vol.Invalid) as ex: + _LOGGER.warning( + "Unable to parse conditions.yaml for the %s integration: %s", + integration.domain, + ex, + ) + return {} + + +def _load_conditions_files( + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: + """Load condition files for multiple integrations.""" + return { + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_conditions_file(integration).items() + } + for integration in integrations + } + + +async def async_get_all_descriptions( + hass: HomeAssistant, +) -> dict[str, dict[str, Any] | None]: + """Return descriptions (i.e. user documentation) for all conditions.""" + descriptions_cache = hass.data[CONDITION_DESCRIPTION_CACHE] + + conditions = hass.data[CONDITIONS] + # See if there are new conditions not seen before. + # Any condition that we saw before already has an entry in description_cache. + all_conditions = set(conditions) + previous_all_conditions = set(descriptions_cache) + # If the conditions are the same, we can return the cache + if previous_all_conditions == all_conditions: + return descriptions_cache + + # Files we loaded for missing descriptions + new_conditions_descriptions: dict[str, dict[str, Any]] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new conditions get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + conditions = conditions.copy() + + if missing_conditions := all_conditions.difference(descriptions_cache): + domains_with_missing_conditions = { + conditions[missing_condition] for missing_condition in missing_conditions + } + ints_or_excs = await async_get_integrations( + hass, domains_with_missing_conditions + ) + integrations: list[Integration] = [] + for domain, int_or_exc in ints_or_excs.items(): + if type(int_or_exc) is Integration and int_or_exc.has_conditions: + integrations.append(int_or_exc) + continue + if TYPE_CHECKING: + assert isinstance(int_or_exc, Exception) + _LOGGER.debug( + "Failed to load conditions.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) + + if integrations: + new_conditions_descriptions = await hass.async_add_executor_job( + _load_conditions_files, integrations + ) + + # Make a copy of the old cache and add missing descriptions to it + new_descriptions_cache = descriptions_cache.copy() + for missing_condition in missing_conditions: + domain = conditions[missing_condition] + + if ( + yaml_description := new_conditions_descriptions.get(domain, {}).get( + missing_condition + ) + ) is None: + _LOGGER.debug( + "No condition descriptions found for condition %s, skipping", + missing_condition, + ) + new_descriptions_cache[missing_condition] = None + continue + + description = {"fields": yaml_description.get("fields", {})} + + new_descriptions_cache[missing_condition] = description + + hass.data[CONDITION_DESCRIPTION_CACHE] = new_descriptions_cache + return new_descriptions_cache diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 1671e8e2cc2..0f8bdfd7793 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -155,7 +155,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def name(self) -> str: """Name of the implementation.""" - return "Configuration.yaml" + return "Local application credentials" @property def domain(self) -> str: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 5445cb51ac9..c2ebddf8012 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -644,6 +644,13 @@ def slug(value: Any) -> str: raise vol.Invalid(f"invalid slug {value} (try {slg})") +def underscore_slug(value: Any) -> str: + """Validate value is a valid slug, possibly starting with an underscore.""" + if value.startswith("_"): + return f"_{slug(value[1:])}" + return slug(value) + + def schema_with_slug_keys( value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug ) -> Callable: @@ -1537,22 +1544,6 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict[str, Any]: return key_dependency("for", "state")(validated) -SUN_CONDITION_SCHEMA = vol.All( - vol.Schema( - { - **CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "sun", - vol.Optional("before"): sun_event, - vol.Optional("before_offset"): time_period, - vol.Optional("after"): vol.All( - vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) - ), - vol.Optional("after_offset"): time_period, - } - ), - has_at_least_one_key("before", "after"), -) - TEMPLATE_CONDITION_SCHEMA = vol.Schema( { **CONDITION_BASE_SCHEMA, @@ -1586,18 +1577,6 @@ TRIGGER_CONDITION_SCHEMA = vol.Schema( } ) -ZONE_CONDITION_SCHEMA = vol.Schema( - { - **CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "zone", - vol.Required(CONF_ENTITY_ID): entity_ids, - vol.Required("zone"): entity_ids, - # To support use_trigger_value in automation - # Deprecated 2016/04/25 - vol.Optional("event"): vol.Any("enter", "leave"), - } -) - AND_CONDITION_SCHEMA = vol.Schema( { **CONDITION_BASE_SCHEMA, @@ -1745,7 +1724,6 @@ BUILT_IN_CONDITIONS: ValueSchemas = { "template": TEMPLATE_CONDITION_SCHEMA, "time": TIME_CONDITION_SCHEMA, "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, } diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 65eb2786aaf..9ace020f342 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -31,27 +31,27 @@ class _BaseFlowManagerView(HomeAssistantView, Generic[_FlowManagerT]): def _prepare_result_json( self, result: data_entry_flow.FlowResult - ) -> data_entry_flow.FlowResult: - """Convert result to JSON.""" + ) -> dict[str, Any]: + """Convert result to JSON serializable dict.""" if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: - data = result.copy() - data.pop("result") - data.pop("data") - data.pop("context") - return data + assert "result" not in result + return { + key: val + for key, val in result.items() + if key not in ("data", "context") + } + + data = dict(result) if "data_schema" not in result: - return result + return data - data = result.copy() - - if (schema := data["data_schema"]) is None: - data["data_schema"] = [] # type: ignore[typeddict-item] # json result type + if (schema := result["data_schema"]) is None: + data["data_schema"] = [] else: data["data_schema"] = voluptuous_serialize.convert( schema, custom_serializer=cv.custom_serializer ) - return data diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 20b5b7ebab9..29d9237de05 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -197,7 +197,7 @@ def _print_deprecation_warning_internal_impl( logger = logging.getLogger(module_name) if breaks_in_ha_version: - breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + breaks_in = f" It will be removed in HA Core {breaks_in_ha_version}." else: breaks_in = "" try: @@ -205,9 +205,10 @@ def _print_deprecation_warning_internal_impl( except MissingIntegrationFrame: if log_when_no_integration_is_found: logger.warning( - "%s is a deprecated %s%s. Use %s instead", - obj_name, + "The deprecated %s %s was %s.%s Use %s instead", description, + obj_name, + verb, breaks_in, replacement, ) @@ -219,25 +220,22 @@ def _print_deprecation_warning_internal_impl( module=integration_frame.module, ) logger.warning( - ( - "%s was %s from %s, this is a deprecated %s%s. Use %s instead," - " please %s" - ), + ("The deprecated %s %s was %s from %s.%s Use %s instead, please %s"), + description, obj_name, verb, integration_frame.integration, - description, breaks_in, replacement, report_issue, ) else: logger.warning( - "%s was %s from %s, this is a deprecated %s%s. Use %s instead", + "The deprecated %s %s was %s from %s.%s Use %s instead", + description, obj_name, verb, integration_frame.integration, - description, breaks_in, replacement, ) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index f1404bb068b..bf0e2ab31be 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -21,6 +21,19 @@ def async_entity_id_to_device_id( return entity.device_id +@callback +def async_entity_id_to_device( + hass: HomeAssistant, + entity_id_or_uuid: str, +) -> dr.DeviceEntry | None: + """Resolve the device entry for the entity id or entity uuid.""" + + if (device_id := async_entity_id_to_device_id(hass, entity_id_or_uuid)) is None: + return None + + return dr.async_get(hass).async_get(device_id) + + @callback def async_device_info_to_link_from_entity( hass: HomeAssistant, diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index a6313381492..c7f7d4c369d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -32,6 +32,7 @@ from homeassistant.util.json import format_unserializable_data from . import storage, translation from .debounce import Debouncer +from .deprecation import deprecated_function from .frame import ReportBehavior, report_usage from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType @@ -56,7 +57,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event ) STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 10 +STORAGE_VERSION_MINOR = 11 CLEANUP_DELAY = 10 @@ -67,6 +68,7 @@ CONNECTION_ZIGBEE = "zigbee" ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 +# Can be removed when suggested_area is removed from DeviceEntry RUNTIME_ONLY_ATTRS = {"suggested_area"} CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"} @@ -144,13 +146,21 @@ DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"} -class _EventDeviceRegistryUpdatedData_CreateRemove(TypedDict): - """EventDeviceRegistryUpdated data for action type 'create' and 'remove'.""" +class _EventDeviceRegistryUpdatedData_Create(TypedDict): + """EventDeviceRegistryUpdated data for action type 'create'.""" - action: Literal["create", "remove"] + action: Literal["create"] device_id: str +class _EventDeviceRegistryUpdatedData_Remove(TypedDict): + """EventDeviceRegistryUpdated data for action type 'remove'.""" + + action: Literal["remove"] + device_id: str + device: dict[str, Any] + + class _EventDeviceRegistryUpdatedData_Update(TypedDict): """EventDeviceRegistryUpdated data for action type 'update'.""" @@ -160,7 +170,8 @@ class _EventDeviceRegistryUpdatedData_Update(TypedDict): type EventDeviceRegistryUpdatedData = ( - _EventDeviceRegistryUpdatedData_CreateRemove + _EventDeviceRegistryUpdatedData_Create + | _EventDeviceRegistryUpdatedData_Remove | _EventDeviceRegistryUpdatedData_Update ) @@ -266,6 +277,48 @@ def _validate_configuration_url(value: Any) -> str | None: return url_as_str +@lru_cache(maxsize=512) +def format_mac(mac: str) -> str: + """Format the mac address string for entry into dev reg.""" + to_test = mac + + if len(to_test) == 17 and to_test.count(":") == 5: + return to_test.lower() + + if len(to_test) == 17 and to_test.count("-") == 5: + to_test = to_test.replace("-", "") + elif len(to_test) == 14 and to_test.count(".") == 2: + to_test = to_test.replace(".", "") + + if len(to_test) == 12: + # no : included + return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2)) + + # Not sure how formatted, return original + return mac + + +def _normalize_connections( + connections: Iterable[tuple[str, str]], +) -> set[tuple[str, str]]: + """Normalize connections to ensure we can match mac addresses.""" + return { + (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) + for key, value in connections + } + + +def _normalize_connections_validator( + instance: Any, + attribute: Any, + connections: Iterable[tuple[str, str]], +) -> None: + """Check connections normalization used as attrs validator.""" + for key, value in connections: + if key == CONNECTION_NETWORK_MAC and format_mac(value) != value: + raise ValueError(f"Invalid mac address format: {value}") + + @attr.s(frozen=True, slots=True) class DeviceEntry: """Device Registry Entry.""" @@ -274,7 +327,9 @@ class DeviceEntry: config_entries: set[str] = attr.ib(converter=set, factory=set) config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) configuration_url: str | None = attr.ib(default=None) - connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set) + connections: set[tuple[str, str]] = attr.ib( + converter=set, factory=set, validator=_normalize_connections_validator + ) created_at: datetime = attr.ib(factory=utcnow) disabled_by: DeviceEntryDisabler | None = attr.ib(default=None) entry_type: DeviceEntryType | None = attr.ib(default=None) @@ -290,7 +345,8 @@ class DeviceEntry: name: str | None = attr.ib(default=None) primary_config_entry: str | None = attr.ib(default=None) serial_number: str | None = attr.ib(default=None) - suggested_area: str | None = attr.ib(default=None) + # Suggested area is deprecated and will be removed from DeviceEntry in 2026.9. + _suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) # This value is not stored, just used to keep track of events to fire. @@ -389,6 +445,14 @@ class DeviceEntry: ) ) + @property + @deprecated_function( + "code which ignores suggested_area", breaks_in_ha_version="2026.9" + ) + def suggested_area(self) -> str | None: + """Return the suggested area for this device entry.""" + return self._suggested_area + @attr.s(frozen=True, slots=True) class DeletedDeviceEntry: @@ -397,7 +461,9 @@ class DeletedDeviceEntry: area_id: str | None = attr.ib() config_entries: set[str] = attr.ib() config_entries_subentries: dict[str, set[str | None]] = attr.ib() - connections: set[tuple[str, str]] = attr.ib() + connections: set[tuple[str, str]] = attr.ib( + validator=_normalize_connections_validator + ) created_at: datetime = attr.ib() disabled_by: DeviceEntryDisabler | None = attr.ib() id: str = attr.ib() @@ -459,31 +525,10 @@ class DeletedDeviceEntry: ) -@lru_cache(maxsize=512) -def format_mac(mac: str) -> str: - """Format the mac address string for entry into dev reg.""" - to_test = mac - - if len(to_test) == 17 and to_test.count(":") == 5: - return to_test.lower() - - if len(to_test) == 17 and to_test.count("-") == 5: - to_test = to_test.replace("-", "") - elif len(to_test) == 14 and to_test.count(".") == 2: - to_test = to_test.replace(".", "") - - if len(to_test) == 12: - # no : included - return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2)) - - # Not sure how formatted, return original - return mac - - class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" - async def _async_migrate_func( + async def _async_migrate_func( # noqa: C901 self, old_major_version: int, old_minor_version: int, @@ -559,6 +604,16 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): device["disabled_by"] = None device["labels"] = [] device["name_by_user"] = None + if old_minor_version < 11: + # Normalization of stored CONNECTION_NETWORK_MAC, introduced in 2025.8 + for device in old_data["devices"]: + device["connections"] = _normalize_connections( + device["connections"] + ) + for device in old_data["deleted_devices"]: + device["connections"] = _normalize_connections( + device["connections"] + ) if old_major_version > 2: raise NotImplementedError @@ -851,7 +906,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if device is None: deleted_device = self.deleted_devices.get_entry(identifiers, connections) if deleted_device is None: - device = DeviceEntry(is_new=True) + area_id: str | None = None + if ( + suggested_area is not None + and suggested_area is not UNDEFINED + and suggested_area != "" + ): + # Circular dep + from . import area_registry as ar # noqa: PLC0415 + + area = ar.async_get(self.hass).async_get_or_create(suggested_area) + area_id = area.id + device = DeviceEntry(is_new=True, area_id=area_id) + else: self.deleted_devices.pop(deleted_device.id) device = deleted_device.to_device_entry( @@ -906,7 +973,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id=model_id, name=name, serial_number=serial_number, - suggested_area=suggested_area, + _suggested_area=suggested_area, sw_version=sw_version, via_device_id=via_device_id, ) @@ -945,6 +1012,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): remove_config_entry_id: str | UndefinedType = UNDEFINED, remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, + # _suggested_area is used internally by the device registry and must + # not be set by integrations. + _suggested_area: str | None | UndefinedType = UNDEFINED, + # suggested_area is deprecated and will be removed in 2026.9 suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, @@ -1010,19 +1081,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Cannot define both merge_identifiers and new_identifiers" ) - if ( - suggested_area is not None - and suggested_area is not UNDEFINED - and suggested_area != "" - and area_id is UNDEFINED - and old.area_id is None - ): - # Circular dep - from . import area_registry as ar # noqa: PLC0415 - - area = ar.async_get(self.hass).async_get_or_create(suggested_area) - area_id = area.id - if add_config_entry_id is not UNDEFINED: if add_config_subentry_id is UNDEFINED: # Interpret not specifying a subentry as None (the main entry) @@ -1100,6 +1158,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values["config_entries_subentries"] = config_entries_subentries old_values["config_entries_subentries"] = old.config_entries_subentries + if suggested_area is not UNDEFINED: + report_usage( + "passes a suggested_area to device_registry.async_update device", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.9.0", + ) + + if _suggested_area is not UNDEFINED: + suggested_area = _suggested_area + added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None @@ -1153,7 +1221,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("name", name), ("name_by_user", name_by_user), ("serial_number", serial_number), - ("suggested_area", suggested_area), ("sw_version", sw_version), ("via_device_id", via_device_id), ): @@ -1161,12 +1228,18 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values[attr_name] = value old_values[attr_name] = getattr(old, attr_name) + # Can be removed when suggested_area is removed from DeviceEntry + if suggested_area is not UNDEFINED and suggested_area != old._suggested_area: # noqa: SLF001 + new_values["suggested_area"] = suggested_area + old_values["suggested_area"] = old._suggested_area # noqa: SLF001 + if old.is_new: new_values["is_new"] = False if not new_values: return old + # This condition can be removed when suggested_area is removed from DeviceEntry if not RUNTIME_ONLY_ATTRS.issuperset(new_values): # Change modified_at if we are changing something that we store new_values["modified_at"] = utcnow() @@ -1189,6 +1262,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # firing events for data we have nothing to compare # against since its never saved on disk if RUNTIME_ONLY_ATTRS.issuperset(new_values): + # This can be removed when suggested_area is removed from DeviceEntry return new self.async_schedule_save() @@ -1274,8 +1348,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.async_update_device(other_device.id, via_device_id=None) self.hass.bus.async_fire_internal( EVENT_DEVICE_REGISTRY_UPDATED, - _EventDeviceRegistryUpdatedData_CreateRemove( - action="remove", device_id=device_id + _EventDeviceRegistryUpdatedData_Remove( + action="remove", device_id=device_id, device=device.dict_repr ), ) self.async_schedule_save() @@ -1696,11 +1770,3 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: debounced_cleanup.async_cancel() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop) - - -def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]: - """Normalize connections to ensure we can match mac addresses.""" - return { - (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) - for key, value in connections - } diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 832bbf219f8..6272495bcec 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -66,7 +66,7 @@ from .typing import UNDEFINED, StateType, UndefinedType timer = time.time if TYPE_CHECKING: - from .entity_platform import EntityPlatform + from .entity_platform import EntityPlatform, PlatformData _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 @@ -92,7 +92,11 @@ def async_setup(hass: HomeAssistant) -> None: @bind_hass @singleton.singleton(DATA_ENTITY_SOURCE) def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: - """Get the entity sources.""" + """Get the entity sources. + + Items are added to this dict by Entity.async_internal_added_to_hass and + removed by Entity.async_internal_will_remove_from_hass. + """ return {} @@ -211,16 +215,19 @@ class StateInfo(TypedDict): class EntityPlatformState(Enum): """The platform state of an entity.""" - # Not Added: Not yet added to a platform, polling updates - # are written to the state machine. + # Not Added: Not yet added to a platform, states are not written to the + # state machine. NOT_ADDED = auto() - # Added: Added to a platform, polling updates - # are written to the state machine. + # Adding: Preparing for adding to a platform, states are not written to the + # state machine. + ADDING = auto() + + # Added: Added to a platform, states are written to the state machine. ADDED = auto() - # Removed: Removed from a platform, polling updates - # are not written to the state machine. + # Removed: Removed from a platform, states are not written to the + # state machine. REMOVED = auto() @@ -442,6 +449,7 @@ class Entity( # While not purely typed, it makes typehinting more useful for us # and removes the need for constant None checks or asserts. platform: EntityPlatform = None # type: ignore[assignment] + platform_data: PlatformData = None # type: ignore[assignment] # Entity description instance for this Entity entity_description: EntityDescription @@ -586,7 +594,7 @@ class Entity( return not self._attr_name if ( name_translation_key := self._name_translation_key - ) and name_translation_key in self.platform.platform_translations: + ) and name_translation_key in self.platform_data.platform_translations: return False if hasattr(self, "entity_description"): return not self.entity_description.name @@ -609,9 +617,9 @@ class Entity( if not self.has_entity_name: return None device_class_key = self.device_class or "_" - platform = self.platform + platform_domain = self.platform_data.domain name_translation_key = ( - f"component.{platform.domain}.entity_component.{device_class_key}.name" + f"component.{platform_domain}.entity_component.{device_class_key}.name" ) return component_translations.get(name_translation_key) @@ -619,13 +627,13 @@ class Entity( def _object_id_device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" return self._device_class_name_helper( - self.platform.object_id_component_translations + self.platform_data.object_id_component_translations ) @cached_property def _device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" - return self._device_class_name_helper(self.platform.component_translations) + return self._device_class_name_helper(self.platform_data.component_translations) def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class.""" @@ -636,9 +644,9 @@ class Entity( """Return translation key for entity name.""" if self.translation_key is None: return None - platform = self.platform + platform_data = self.platform_data return ( - f"component.{platform.platform_name}.entity.{platform.domain}" + f"component.{platform_data.platform_name}.entity.{platform_data.domain}" f".{self.translation_key}.name" ) @@ -647,14 +655,14 @@ class Entity( """Return translation key for unit of measurement.""" if self.translation_key is None: return None - if self.platform is None: + if self.platform_data is None: raise ValueError( f"Entity {type(self)} cannot have a translation key for " "unit of measurement before being added to the entity platform" ) - platform = self.platform + platform_data = self.platform_data return ( - f"component.{platform.platform_name}.entity.{platform.domain}" + f"component.{platform_data.platform_name}.entity.{platform_data.domain}" f".{self.translation_key}.unit_of_measurement" ) @@ -717,13 +725,13 @@ class Entity( # value. type.__getattribute__(self.__class__, "name") is type.__getattribute__(Entity, "name") - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - and self.platform + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + and self.platform_data ): name = self._name_internal( self._object_id_device_class_name, - self.platform.object_id_platform_translations, + self.platform_data.object_id_platform_translations, ) else: name = self.name @@ -732,13 +740,13 @@ class Entity( @cached_property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if not self.platform: + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + if not self.platform_data: return self._name_internal(None, {}) return self._name_internal( self._device_class_name, - self.platform.platform_translations, + self.platform_data.platform_translations, ) @cached_property @@ -979,7 +987,7 @@ class Entity( raise RuntimeError(f"Attribute hass is None for {self}") # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 + # EntityComponent and can be removed in HA Core 2026.8 if self.platform is None and not self._no_platform_reported: # type: ignore[unreachable] report_issue = self._suggest_report_issue() # type: ignore[unreachable] _LOGGER.warning( @@ -1118,21 +1126,24 @@ class Entity( @callback def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" - if self._platform_state is EntityPlatformState.REMOVED: - # Polling returned after the entity has already been removed - return - - if (entry := self.registry_entry) and entry.disabled_by: - if not self._disabled_reported: - self._disabled_reported = True - _LOGGER.warning( - ( - "Entity %s is incorrectly being triggered for updates while it" - " is disabled. This is a bug in the %s integration" - ), - self.entity_id, - self.platform.platform_name, - ) + # The check for self.platform guards against integrations not using an + # EntityComponent (which has not been allowed since HA Core 2024.1) + if not self.platform: + if self._platform_state is EntityPlatformState.REMOVED: + # Don't write state if the entity is not added to the platform. + return + elif self._platform_state is not EntityPlatformState.ADDED: + if (entry := self.registry_entry) and entry.disabled_by: + if not self._disabled_reported: + self._disabled_reported = True + _LOGGER.warning( + ( + "Entity %s is incorrectly being triggered for updates while it" + " is disabled. This is a bug in the %s integration" + ), + self.entity_id, + self.platform.platform_name, + ) return state_calculate_start = timer() @@ -1141,7 +1152,7 @@ class Entity( ) time_now = timer() - if entry: + if entry := self.registry_entry: # Make sure capabilities in the entity registry are up to date. Capabilities # include capability attributes, device class and supported features supported_features = supported_features or 0 @@ -1341,8 +1352,9 @@ class Entity( self.hass = hass self.platform = platform + self.platform_data = platform.platform_data self.parallel_updates = parallel_updates - self._platform_state = EntityPlatformState.ADDED + self._platform_state = EntityPlatformState.ADDING def _call_on_remove_callbacks(self) -> None: """Call callbacks registered by async_on_remove.""" @@ -1366,6 +1378,7 @@ class Entity( """Finish adding an entity to a platform.""" await self.async_internal_added_to_hass() await self.async_added_to_hass() + self._platform_state = EntityPlatformState.ADDED self.async_write_ha_state() @final @@ -1483,7 +1496,7 @@ class Entity( Not to be extended by integrations. """ # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 + # EntityComponent and can be removed in HA Core 2026.8 if self.platform: del entity_sources(self.hass)[self.entity_id] @@ -1615,9 +1628,9 @@ class Entity( def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - platform_name = self.platform.platform_name if self.platform else None + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + platform_name = self.platform_data.platform_name if self.platform_data else None return async_suggest_report_issue( self.hass, integration_domain=platform_name, module=type(self).__module__ ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0423a1979bc..bf089dae765 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -44,6 +44,7 @@ from . import ( service, translation, ) +from .deprecation import deprecated_function from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later from .issue_registry import IssueSeverity, async_create_issue @@ -126,6 +127,77 @@ class EntityPlatformModule(Protocol): """Set up an integration platform from a config entry.""" +class PlatformData: + """Information about a platform, used by entities.""" + + def __init__( + self, + hass: HomeAssistant, + *, + domain: str, + platform_name: str, + ) -> None: + """Initialize the base entity platform.""" + self.hass = hass + self.domain = domain + self.platform_name = platform_name + self.component_translations: dict[str, str] = {} + self.platform_translations: dict[str, str] = {} + self.object_id_component_translations: dict[str, str] = {} + self.object_id_platform_translations: dict[str, str] = {} + self.default_language_platform_translations: dict[str, str] = {} + + async def _async_get_translations( + self, language: str, category: str, integration: str + ) -> dict[str, str]: + """Get translations for a language, category, and integration.""" + try: + return await translation.async_get_translations( + self.hass, language, category, {integration} + ) + except Exception as err: # noqa: BLE001 + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + + async def async_load_translations(self) -> None: + """Load translations.""" + hass = self.hass + object_id_language = ( + hass.config.language + if hass.config.language in languages.NATIVE_ENTITY_IDS + else languages.DEFAULT_LANGUAGE + ) + config_language = hass.config.language + self.component_translations = await self._async_get_translations( + config_language, "entity_component", self.domain + ) + self.platform_translations = await self._async_get_translations( + config_language, "entity", self.platform_name + ) + if object_id_language == config_language: + self.object_id_component_translations = self.component_translations + self.object_id_platform_translations = self.platform_translations + else: + self.object_id_component_translations = await self._async_get_translations( + object_id_language, "entity_component", self.domain + ) + self.object_id_platform_translations = await self._async_get_translations( + object_id_language, "entity", self.platform_name + ) + if config_language == languages.DEFAULT_LANGUAGE: + self.default_language_platform_translations = self.platform_translations + else: + self.default_language_platform_translations = ( + await self._async_get_translations( + languages.DEFAULT_LANGUAGE, "entity", self.platform_name + ) + ) + + class EntityPlatform: """Manage the entities for a single platform. @@ -147,8 +219,6 @@ class EntityPlatform: """Initialize the entity platform.""" self.hass = hass self.logger = logger - self.domain = domain - self.platform_name = platform_name self.platform = platform self.scan_interval = scan_interval self.scan_interval_seconds = scan_interval.total_seconds() @@ -157,11 +227,6 @@ class EntityPlatform: # Storage for entities for this specific platform only # which are indexed by entity_id self.entities: dict[str, Entity] = {} - self.component_translations: dict[str, str] = {} - self.platform_translations: dict[str, str] = {} - self.object_id_component_translations: dict[str, str] = {} - self.object_id_platform_translations: dict[str, str] = {} - self.default_language_platform_translations: dict[str, str] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -195,6 +260,10 @@ class EntityPlatform: DATA_DOMAIN_PLATFORM_ENTITIES, {} ).setdefault(key, {}) + self.platform_data = PlatformData( + hass, domain=domain, platform_name=platform_name + ) + def __repr__(self) -> str: """Represent an EntityPlatform.""" return ( @@ -362,7 +431,7 @@ class EntityPlatform: hass = self.hass full_name = f"{self.platform_name}.{self.domain}" - await self.async_load_translations() + await self.platform_data.async_load_translations() logger.info("Setting up %s", full_name) warn_task = hass.loop.call_at( @@ -457,56 +526,6 @@ class EntityPlatform: finally: warn_task.cancel() - async def _async_get_translations( - self, language: str, category: str, integration: str - ) -> dict[str, str]: - """Get translations for a language, category, and integration.""" - try: - return await translation.async_get_translations( - self.hass, language, category, {integration} - ) - except Exception as err: # noqa: BLE001 - _LOGGER.debug( - "Could not load translations for %s", - integration, - exc_info=err, - ) - return {} - - async def async_load_translations(self) -> None: - """Load translations.""" - hass = self.hass - object_id_language = ( - hass.config.language - if hass.config.language in languages.NATIVE_ENTITY_IDS - else languages.DEFAULT_LANGUAGE - ) - config_language = hass.config.language - self.component_translations = await self._async_get_translations( - config_language, "entity_component", self.domain - ) - self.platform_translations = await self._async_get_translations( - config_language, "entity", self.platform_name - ) - if object_id_language == config_language: - self.object_id_component_translations = self.component_translations - self.object_id_platform_translations = self.platform_translations - else: - self.object_id_component_translations = await self._async_get_translations( - object_id_language, "entity_component", self.domain - ) - self.object_id_platform_translations = await self._async_get_translations( - object_id_language, "entity", self.platform_name - ) - if config_language == languages.DEFAULT_LANGUAGE: - self.default_language_platform_translations = self.platform_translations - else: - self.default_language_platform_translations = ( - await self._async_get_translations( - languages.DEFAULT_LANGUAGE, "entity", self.platform_name - ) - ) - def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -825,21 +844,25 @@ class EntityPlatform: entity.add_to_platform_abort() return - if self.config_entry and (device_info := entity.device_info): - try: - device = dev_reg.async_get(self.hass).async_get_or_create( - config_entry_id=self.config_entry.entry_id, - config_subentry_id=config_subentry_id, - **device_info, - ) - except dev_reg.DeviceInfoError as exc: - self.logger.error( - "%s: Not adding entity with invalid device info: %s", - self.platform_name, - str(exc), - ) - entity.add_to_platform_abort() - return + device: dev_reg.DeviceEntry | None + if self.config_entry: + if device_info := entity.device_info: + try: + device = dev_reg.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + config_subentry_id=config_subentry_id, + **device_info, + ) + except dev_reg.DeviceInfoError as exc: + self.logger.error( + "%s: Not adding entity with invalid device info: %s", + self.platform_name, + str(exc), + ) + entity.add_to_platform_abort() + return + else: + device = entity.device_entry else: device = None @@ -1116,6 +1139,87 @@ class EntityPlatform: ]: await asyncio.gather(*tasks) + @property + def domain(self) -> str: + """Return the domain (e.g. light).""" + return self.platform_data.domain + + @property + def platform_name(self) -> str: + """Return the platform name (e.g hue).""" + return self.platform_data.platform_name + + @property + @deprecated_function( + "platform_data.component_translations", + breaks_in_ha_version="2026.8", + ) + def component_translations(self) -> dict[str, str]: + """Return the component translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.component_translations + + @property + @deprecated_function( + "platform_data.platform_translations", + breaks_in_ha_version="2026.8", + ) + def platform_translations(self) -> dict[str, str]: + """Return the platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.platform_translations + + @property + @deprecated_function( + "platform_data.object_id_component_translations", + breaks_in_ha_version="2026.8", + ) + def object_id_component_translations(self) -> dict[str, str]: + """Return the object ID component translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.object_id_component_translations + + @property + @deprecated_function( + "platform_data.object_id_platform_translations", + breaks_in_ha_version="2026.8", + ) + def object_id_platform_translations(self) -> dict[str, str]: + """Return the object ID platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.object_id_platform_translations + + @property + @deprecated_function( + "platform_data.default_language_platform_translations", + breaks_in_ha_version="2026.8", + ) + def default_language_platform_translations(self) -> dict[str, str]: + """Return the default language platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.default_language_platform_translations + + @deprecated_function( + "platform_data.async_load_translations", + breaks_in_ha_version="2026.8", + ) + async def async_load_translations(self) -> None: + """Load translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return await self.platform_data.async_load_translations() + @callback def async_calculate_suggested_object_id( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0b61c3e8f16..d972b421fc4 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1103,8 +1103,20 @@ class EntityRegistry(BaseRegistry): entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) + removed_device_dict = event.data["device"] for entity in entities: - self.async_remove(entity.entity_id) + config_entry_id = entity.config_entry_id + if ( + config_entry_id in removed_device_dict["config_entries"] + and entity.config_subentry_id + in removed_device_dict["config_entries_subentries"][config_entry_id] + ): + self.async_remove(entity.entity_id) + else: + if entity.entity_id not in self.entities: + # Entity has been removed already, skip it + continue + self.async_update_entity(entity.entity_id, device_id=None) return if event.data["action"] != "update": @@ -1121,29 +1133,38 @@ class EntityRegistry(BaseRegistry): # Remove entities which belong to config entries no longer associated with the # device - entities = async_entries_for_device( - self, event.data["device_id"], include_disabled_entities=True - ) - for entity in entities: - if ( - entity.config_entry_id is not None - and entity.config_entry_id not in device.config_entries - ): - self.async_remove(entity.entity_id) + if old_config_entries := event.data["changes"].get("config_entries"): + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + config_entry_id = entity.config_entry_id + if ( + entity.config_entry_id in old_config_entries + and entity.config_entry_id not in device.config_entries + ): + self.async_remove(entity.entity_id) # Remove entities which belong to config subentries no longer associated with the # device - entities = async_entries_for_device( - self, event.data["device_id"], include_disabled_entities=True - ) - for entity in entities: - if ( - (config_entry_id := entity.config_entry_id) is not None - and config_entry_id in device.config_entries - and entity.config_subentry_id - not in device.config_entries_subentries[config_entry_id] - ): - self.async_remove(entity.entity_id) + if old_config_entries_subentries := event.data["changes"].get( + "config_entries_subentries" + ): + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + config_entry_id = entity.config_entry_id + config_subentry_id = entity.config_subentry_id + if ( + config_entry_id in device.config_entries + and config_entry_id in old_config_entries_subentries + and config_subentry_id + in old_config_entries_subentries[config_entry_id] + and config_subentry_id + not in device.config_entries_subentries[config_entry_id] + ): + self.async_remove(entity.entity_id) # Re-enable disabled entities if the device is no longer disabled if not device.disabled: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index baf1f144a3f..39cff22396a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -316,6 +316,10 @@ def async_track_state_change_event( Unlike async_track_state_change, async_track_state_change_event passes the full event to the callback. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + In order to avoid having to iterate a long list of EVENT_STATE_CHANGED and fire and create a job for each one, we keep a dict of entity ids that @@ -398,7 +402,7 @@ def _async_track_state_change_event( _KEYED_TRACK_STATE_REPORT = _KeyedEventTracker( key=_TRACK_STATE_REPORT_DATA, event_type=EVENT_STATE_REPORTED, - dispatcher_callable=_async_dispatch_entity_id_event, + dispatcher_callable=_async_dispatch_entity_id_event_soon, filter_callable=_async_state_filter, ) @@ -866,19 +870,21 @@ def async_track_state_change_filtered( ) -> _TrackStateChangeFiltered: """Track state changes with a TrackStates filter that can be updated. - Parameters - ---------- - hass - Home assistant object. - track_states - A TrackStates data class. - action - Callable to call with results. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. - Returns - ------- - Object used to update the listeners (async_update_listeners) with a new - TrackStates or cancel the tracking (async_remove). + Args: + hass: + Home assistant object. + track_states: + A TrackStates data class. + action: + Callable to call with results. + + Returns: + Object used to update the listeners (async_update_listeners) with a new + TrackStates or cancel the tracking (async_remove). """ tracker = _TrackStateChangeFiltered(hass, track_states, action) @@ -907,29 +913,26 @@ def async_track_template( exception, the listener will still be registered but will only fire if the template result becomes true without an exception. - Action arguments - ---------------- - entity_id - ID of the entity that triggered the state change. - old_state - The old state of the entity that changed. - new_state - New state of the entity that changed. + Action args: + entity_id: + ID of the entity that triggered the state change. + old_state: + The old state of the entity that changed. + new_state: + New state of the entity that changed. - Parameters - ---------- - hass - Home assistant object. - template - The template to calculate. - action - Callable to call with results. See above for arguments. - variables - Variables to pass to the template. + Args: + hass: + Home assistant object. + template: + The template to calculate. + action: + Callable to call with results. See above for arguments. + variables: + Variables to pass to the template. - Returns - ------- - Callable to unregister the listener. + Returns: + Callable to unregister the listener. """ job = HassJob(action, f"track template {template}") @@ -1353,34 +1356,36 @@ def async_track_template_result( then whenever the output from the template changes. The template will be reevaluated if any states referenced in the last run of the template change, or if manually triggered. If the result of the - evaluation is different from the previous run, the listener is passed + evaluation is different from the previous run, the action is passed the result. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + If the template results in an TemplateError, this will be returned to the listener the first time this happens but not for subsequent errors. Once the template returns to a non-error condition the result is sent to the action as usual. - Parameters - ---------- - hass - Home assistant object. - track_templates - An iterable of TrackTemplate. - action - Callable to call with results. - strict - When set to True, raise on undefined variables. - log_fn - If not None, template error messages will logging by calling log_fn - instead of the normal logging facility. - has_super_template - When set to True, the first template will block rendering of other - templates if it doesn't render as True. + Args: + hass: + Home assistant object. + track_templates: + An iterable of TrackTemplate. + action: + Callable to call with results. + strict: + When set to True, raise on undefined variables. + log_fn: + If not None, template error messages will logging by calling log_fn + instead of the normal logging facility. + has_super_template: + When set to True, the first template will block rendering of other + templates if it doesn't render as True. - Returns - ------- - Info object used to unregister the listener, and refresh the template. + Returns: + Info object used to unregister the listener, and refresh the template. """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index ca7b097d90d..2d9b368254a 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -2,11 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass import enum import functools +import inspect import linecache import logging import sys @@ -185,6 +185,21 @@ def report_usage( """ if (hass := _hass.hass) is None: raise RuntimeError("Frame helper not set up") + integration_frame: IntegrationFrame | None = None + integration_frame_err: MissingIntegrationFrame | None = None + if not integration_domain: + try: + integration_frame = get_integration_frame( + exclude_integrations=exclude_integrations + ) + except MissingIntegrationFrame as err: + # We need to be careful with assigning the error here as it affects the + # cleanup of objects referenced from the stack trace as seen in + # https://github.com/home-assistant/core/pull/148021#discussion_r2182379834 + # When core_behavior is ReportBehavior.ERROR, we will re-raise the error, + # so we can safely assign it to integration_frame_err. + if core_behavior is ReportBehavior.ERROR: + integration_frame_err = err _report_usage_partial = functools.partial( _report_usage, hass, @@ -193,8 +208,9 @@ def report_usage( core_behavior=core_behavior, core_integration_behavior=core_integration_behavior, custom_integration_behavior=custom_integration_behavior, - exclude_integrations=exclude_integrations, integration_domain=integration_domain, + integration_frame=integration_frame, + integration_frame_err=integration_frame_err, level=level, ) if hass.loop_thread_id != threading.get_ident(): @@ -212,8 +228,9 @@ def _report_usage( core_behavior: ReportBehavior, core_integration_behavior: ReportBehavior, custom_integration_behavior: ReportBehavior, - exclude_integrations: set[str] | None, integration_domain: str | None, + integration_frame: IntegrationFrame | None, + integration_frame_err: MissingIntegrationFrame | None, level: int, ) -> None: """Report incorrect code usage. @@ -235,12 +252,10 @@ def _report_usage( _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, None) return - try: - integration_frame = get_integration_frame( - exclude_integrations=exclude_integrations + if not integration_frame: + _report_usage_no_integration( + what, core_behavior, breaks_in_ha_version, integration_frame_err ) - except MissingIntegrationFrame as err: - _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, err) return integration_behavior = core_integration_behavior @@ -382,7 +397,7 @@ def _report_usage_no_integration( def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @functools.wraps(func) async def report_use(*args: Any, **kwargs: Any) -> None: diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index 61bb0bcd45d..04a1d2cca76 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -14,17 +14,20 @@ from .event import async_track_entity_registry_updated_event def async_handle_source_entity_changes( hass: HomeAssistant, *, + add_helper_config_entry_to_device: bool = True, helper_config_entry_id: str, set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, - source_entity_removed: Callable[[], Coroutine[Any, Any, None]], + source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. The following changes are handled: - - Entity removal: If the source entity is removed, the helper config entry - is removed, and the helper entity is cleaned up. + - Entity removal: If the source entity is removed: + - If source_entity_removed is provided, it is called to handle the removal. + - If source_entity_removed is not provided, The helper entity is updated to + not link to any device. - Entity ID changed: If the source entity's entity ID changes and the source entity is identified by an entity ID, the set_source_entity_id_or_uuid is called. If the source entity is identified by a UUID, the helper config entry @@ -51,7 +54,18 @@ def async_handle_source_entity_changes( data = event.data if data["action"] == "remove": - await source_entity_removed() + if source_entity_removed: + await source_entity_removed() + else: + for ( + helper_entity_entry + ) in entity_registry.entities.get_entries_for_config_entry_id( + helper_config_entry_id + ): + # Update the helper entity to link to the new device (or no device) + entity_registry.async_update_entity( + helper_entity_entry.entity_id, device_id=None + ) if data["action"] != "update": return @@ -88,15 +102,17 @@ def async_handle_source_entity_changes( helper_entity.entity_id, device_id=source_entity_entry.device_id ) - if source_entity_entry.device_id is not None: + if add_helper_config_entry_to_device: + if source_entity_entry.device_id is not None: + device_registry.async_update_device( + source_entity_entry.device_id, + add_config_entry_id=helper_config_entry_id, + ) + device_registry.async_update_device( - source_entity_entry.device_id, - add_config_entry_id=helper_config_entry_id, + source_device_id, remove_config_entry_id=helper_config_entry_id ) - device_registry.async_update_device( - source_device_id, remove_config_entry_id=helper_config_entry_id - ) source_device_id = source_entity_entry.device_id # Reload the config entry so the helper entity is recreated with @@ -111,3 +127,46 @@ def async_handle_source_entity_changes( return async_track_entity_registry_updated_event( hass, source_entity_id, async_registry_updated ) + + +def async_remove_helper_config_entry_from_source_device( + hass: HomeAssistant, + *, + helper_config_entry_id: str, + source_device_id: str, +) -> None: + """Remove helper config entry from source device. + + This is a convenience function to migrate from helpers which added their config + entry to the source device. + """ + device_registry = dr.async_get(hass) + + if ( + not (source_device := device_registry.async_get(source_device_id)) + or helper_config_entry_id not in source_device.config_entries + ): + return + + entity_registry = er.async_get(hass) + helper_entity_entries = er.async_entries_for_config_entry( + entity_registry, helper_config_entry_id + ) + + # Disconnect helper entities from the device to prevent them from + # being removed when the config entry link to the device is removed. + modified_helpers: list[er.RegistryEntry] = [] + for helper in helper_entity_entries: + if helper.device_id != source_device_id: + continue + modified_helpers.append(helper) + entity_registry.async_update_entity(helper.entity_id, device_id=None) + # Remove the helper config entry from the device + device_registry.async_update_device( + source_device_id, remove_config_entry_id=helper_config_entry_id + ) + # Connect the helper entity to the device + for helper in modified_helpers: + entity_registry.async_update_entity( + helper.entity_id, device_id=source_device_id + ) diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 68daf5c7939..e890a8ed087 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from contextvars import ContextVar from http import HTTPStatus +import inspect import logging from typing import Any, Final @@ -45,7 +45,7 @@ def request_handler_factory( hass: HomeAssistant, view: HomeAssistantView, handler: Callable ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" - is_coroutinefunction = asyncio.iscoroutinefunction(handler) + is_coroutinefunction = inspect.iscoroutinefunction(handler) assert is_coroutinefunction or is_callback(handler), ( "Handler should be a coroutine or a callback." ) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 176bcfcd7c4..8af91249200 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -12,39 +12,7 @@ from typing import TYPE_CHECKING, Any, Final import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( - JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS, - JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS, - SerializationError, - format_unserializable_data, - json_loads as _json_loads, -) - -from .deprecation import ( - DeprecatedConstant, - all_with_deprecated_constants, - check_if_deprecated_constant, - deprecated_function, - dir_with_deprecated_constants, -) - -_DEPRECATED_JSON_DECODE_EXCEPTIONS = DeprecatedConstant( - _JSON_DECODE_EXCEPTIONS, "homeassistant.util.json.JSON_DECODE_EXCEPTIONS", "2025.8" -) -_DEPRECATED_JSON_ENCODE_EXCEPTIONS = DeprecatedConstant( - _JSON_ENCODE_EXCEPTIONS, "homeassistant.util.json.JSON_ENCODE_EXCEPTIONS", "2025.8" -) -json_loads = deprecated_function( - "homeassistant.util.json.json_loads", breaks_in_ha_version="2025.8" -)(_json_loads) - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) - +from homeassistant.util.json import SerializationError, format_unserializable_data _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5d9e4c3bdef..dc69916a728 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -183,6 +183,7 @@ class ToolInput: tool_args: dict[str, Any] # Using lambda for default to allow patching in tests id: str = dc_field(default_factory=lambda: ulid_now()) # pylint: disable=unnecessary-lambda + external: bool = False class Tool: @@ -315,10 +316,23 @@ class IntentTool(Tool): assistant=llm_context.assistant, device_id=llm_context.device_id, ) - response = intent_response.as_dict() - del response["language"] - del response["card"] - return response + return IntentResponseDict(intent_response) + + +class IntentResponseDict(dict): + """Dictionary to represent an intent response resulting from a tool call.""" + + def __init__(self, intent_response: Any) -> None: + """Initialize the dictionary.""" + if not isinstance(intent_response, intent.IntentResponse): + super().__init__(intent_response) + return + + result = intent_response.as_dict() + del result["language"] + del result["card"] + super().__init__(result) + self.original = intent_response class NamespacedTool(Tool): @@ -331,7 +345,7 @@ class NamespacedTool(Tool): def __init__(self, namespace: str, tool: Tool) -> None: """Init the class.""" self.namespace = namespace - self.name = f"{namespace}.{tool.name}" + self.name = f"{namespace}__{tool.name}" self.description = tool.description self.parameters = tool.parameters self.tool = tool @@ -458,7 +472,7 @@ class AssistAPI(API): api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), llm_context=llm_context, tools=self._async_get_tools(llm_context, exposed_entities), - custom_serializer=_selector_serializer, + custom_serializer=selector_serializer, ) @callback @@ -701,7 +715,7 @@ def _get_exposed_entities( return data -def _selector_serializer(schema: Any) -> Any: # noqa: C901 +def selector_serializer(schema: Any) -> Any: # noqa: C901 """Convert selectors into OpenAPI schema.""" if not isinstance(schema, selector.Selector): return UNSUPPORTED @@ -777,7 +791,29 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return result if isinstance(schema, selector.ObjectSelector): - return {"type": "object", "additionalProperties": True} + result = {"type": "object"} + if fields := schema.config.get("fields"): + properties = {} + required = [] + for field, field_schema in fields.items(): + properties[field] = convert( + selector.selector(field_schema["selector"]), + custom_serializer=selector_serializer, + ) + if field_schema.get("required"): + required.append(field) + result["properties"] = properties + + if required: + result["required"] = required + else: + result["additionalProperties"] = True + if schema.config.get("multiple"): + result = { + "type": "array", + "items": result, + } + return result if isinstance(schema, selector.SelectSelector): options = [ @@ -899,7 +935,7 @@ class ActionTool(Tool): """Init the class.""" self._domain = domain self._action = action - self.name = f"{domain}.{action}" + self.name = f"{domain}__{action}" # Note: _get_cached_action_parameters only works for services which # add their description directly to the service description cache. # This is not the case for most services, but it is for scripts. diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 93d9a3d06f1..8bc773d85f7 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -95,6 +95,12 @@ class SchemaFlowFormStep(SchemaFlowStep): preview: str | None = None """Optional preview component.""" + description_placeholders: ( + Callable[[SchemaCommonFlowHandler], Coroutine[Any, Any, dict[str, str]]] + | UndefinedType + ) = UNDEFINED + """Optional property to populate description placeholders.""" + @dataclass(slots=True) class SchemaFlowMenuStep(SchemaFlowStep): @@ -257,6 +263,10 @@ class SchemaCommonFlowHandler: if (data_schema := await self._get_schema(form_step)) is None: return await self._show_next_step_or_create_entry(form_step) + description_placeholders: dict[str, str] | None = None + if form_step.description_placeholders is not UNDEFINED: + description_placeholders = await form_step.description_placeholders(self) + suggested_values: dict[str, Any] = {} if form_step.suggested_values is UNDEFINED: suggested_values = self._options @@ -285,6 +295,7 @@ class SchemaCommonFlowHandler: return self._handler.async_show_form( step_id=next_step_id, data_schema=data_schema, + description_placeholders=description_placeholders, errors=errors, last_step=last_step, preview=form_step.preview, diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 06c05f206b5..c1e93fba200 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -117,11 +117,8 @@ def _validate_supported_feature(supported_feature: str) -> int: raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc -def _validate_supported_features(supported_features: int | list[str]) -> int: - """Validate a supported feature and resolve an enum string to its value.""" - - if isinstance(supported_features, int): - return supported_features +def _validate_supported_features(supported_features: list[str]) -> int: + """Validate supported features and resolve enum strings to their value.""" feature_mask = 0 @@ -167,6 +164,22 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( ) +# 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. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration that provided the entity + vol.Optional("integration"): str, + # Domain the entity belongs to + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), + # Device class of the entity + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + } +) + + class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" @@ -186,10 +199,22 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("model"): str, # Model ID of device vol.Optional("model_id"): str, - # Device has to contain entities matching this selector - vol.Optional("entity"): vol.All( - cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] - ), + } +) + + +# Legacy device selector config schema used directly under device selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration linked to it with a config entry + vol.Optional("integration"): str, + # Manufacturer of device + vol.Optional("manufacturer"): str, + # Model of device + vol.Optional("model"): str, } ) @@ -557,49 +582,6 @@ class ConstantSelector(Selector[ConstantSelectorConfig]): return self.config["value"] -class QrErrorCorrectionLevel(StrEnum): - """Possible error correction levels for QR code selector.""" - - LOW = "low" - MEDIUM = "medium" - QUARTILE = "quartile" - HIGH = "high" - - -class QrCodeSelectorConfig(BaseSelectorConfig, total=False): - """Class to represent a QR code selector config.""" - - data: str - scale: int - error_correction_level: QrErrorCorrectionLevel - - -@SELECTORS.register("qr_code") -class QrCodeSelector(Selector[QrCodeSelectorConfig]): - """QR code selector.""" - - selector_type = "qr_code" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - vol.Required("data"): str, - vol.Optional("scale"): int, - vol.Optional("error_correction_level"): vol.All( - vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value - ), - } - ) - - def __init__(self, config: QrCodeSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> Any: - """Validate the passed selection.""" - vol.Schema(vol.Any(str, None))(data) - return self.config["data"] - - class ConversationAgentSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a conversation agent selector config.""" @@ -721,9 +703,13 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA.schema ).extend( { + # Device has to contain entities matching this selector + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), vol.Optional("multiple", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, @@ -791,6 +777,7 @@ class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total exclude_entities: list[str] include_entities: list[str] multiple: bool + reorder: bool filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @@ -801,12 +788,13 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA.schema ).extend( { 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], @@ -848,6 +836,39 @@ class EntitySelector(Selector[EntitySelectorConfig]): return cast(list, vol.Schema([validate])(data)) # Output is a list +class FileSelectorConfig(BaseSelectorConfig): + """Class to represent a file selector config.""" + + accept: str # required + + +@SELECTORS.register("file") +class FileSelector(Selector[FileSelectorConfig]): + """Selector of a file.""" + + selector_type = "file" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept + vol.Required("accept"): str, + } + ) + + def __init__(self, config: FileSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + if not isinstance(data, str): + raise vol.Invalid("Value should be a string") + + UUID(data) + + return data + + class FloorSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an floor selector config.""" @@ -1017,9 +1038,11 @@ class LocationSelector(Selector[LocationSelectorConfig]): return location -class MediaSelectorConfig(BaseSelectorConfig): +class MediaSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a media selector config.""" + accept: list[str] + @SELECTORS.register("media") class MediaSelector(Selector[MediaSelectorConfig]): @@ -1048,9 +1071,19 @@ class MediaSelector(Selector[MediaSelectorConfig]): """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> dict[str, float]: + def __call__(self, data: Any) -> dict[str, str]: """Validate the passed selection.""" - media: dict[str, float] = self.DATA_SCHEMA(data) + schema = { + key: value + for key, value in self.DATA_SCHEMA.schema.items() + if key != "entity_id" + } + + if "accept" not in self.config: + # If accept is not set, the entity_id field is required + schema[vol.Required("entity_id")] = cv.entity_id_or_uuid + + media: dict[str, str] = vol.Schema(schema)(data) return media @@ -1062,6 +1095,7 @@ class NumberSelectorConfig(BaseSelectorConfig, total=False): step: float | Literal["any"] unit_of_measurement: str mode: NumberSelectorMode + translation_key: str class NumberSelectorMode(StrEnum): @@ -1073,10 +1107,12 @@ class NumberSelectorMode(StrEnum): def validate_slider(data: Any) -> Any: """Validate configuration.""" - if data["mode"] == "box": - return data + has_min_max = "min" in data and "max" in data - if "min" not in data or "max" not in data: + if "mode" not in data: + data["mode"] = "slider" if has_min_max else "box" + + if data["mode"] == "slider" and not has_min_max: raise vol.Invalid("min and max are required in slider mode") return data @@ -1099,9 +1135,10 @@ class NumberSelector(Selector[NumberSelectorConfig]): "any", vol.All(vol.Coerce(float), vol.Range(min=1e-3)) ), vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, - vol.Optional(CONF_MODE, default=NumberSelectorMode.SLIDER): vol.All( + vol.Optional(CONF_MODE): vol.All( vol.Coerce(NumberSelectorMode), lambda val: val.value ), + vol.Optional("translation_key"): str, } ), validate_slider, @@ -1173,6 +1210,49 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): return data +class QrErrorCorrectionLevel(StrEnum): + """Possible error correction levels for QR code selector.""" + + LOW = "low" + MEDIUM = "medium" + QUARTILE = "quartile" + HIGH = "high" + + +class QrCodeSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent a QR code selector config.""" + + data: str + scale: int + error_correction_level: QrErrorCorrectionLevel + + +@SELECTORS.register("qr_code") +class QrCodeSelector(Selector[QrCodeSelectorConfig]): + """QR code selector.""" + + selector_type = "qr_code" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Required("data"): str, + vol.Optional("scale"): int, + vol.Optional("error_correction_level"): vol.All( + vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value + ), + } + ) + + def __init__(self, config: QrCodeSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(vol.Any(str, None))(data) + return self.config["data"] + + select_option = vol.All( dict, vol.Schema( @@ -1255,6 +1335,47 @@ class SelectSelector(Selector[SelectSelectorConfig]): return [parent_schema(vol.Schema(str)(val)) for val in data] +class StateSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent an state selector config.""" + + entity_id: str + hide_states: list[str] + multiple: bool + + +@SELECTORS.register("state") +class StateSelector(Selector[StateSelectorConfig]): + """Selector for an entity state.""" + + selector_type = "state" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + 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("multiple", default=False): cv.boolean, + } + ) + + def __init__(self, config: StateSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str | list[str]: + """Validate the passed selection.""" + if not self.config["multiple"]: + state: str = vol.Schema(str)(data) + return state + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] + + class StatisticSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a statistic selector config.""" @@ -1404,39 +1525,6 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] -class StateSelectorConfig(BaseSelectorConfig, total=False): - """Class to represent an state selector config.""" - - entity_id: Required[str] - - -@SELECTORS.register("state") -class StateSelector(Selector[StateSelectorConfig]): - """Selector for an entity state.""" - - selector_type = "state" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - vol.Required("entity_id"): cv.entity_id, - # 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, - } - ) - - def __init__(self, config: StateSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> str: - """Validate the passed selection.""" - state: str = vol.Schema(str)(data) - return state - - @SELECTORS.register("target") class TargetSelector(Selector[TargetSelectorConfig]): """Selector of a target value (area ID, device ID, entity ID etc). @@ -1626,39 +1714,6 @@ class TriggerSelector(Selector[TriggerSelectorConfig]): return vol.Schema(cv.TRIGGER_SCHEMA)(data) -class FileSelectorConfig(BaseSelectorConfig): - """Class to represent a file selector config.""" - - accept: str # required - - -@SELECTORS.register("file") -class FileSelector(Selector[FileSelectorConfig]): - """Selector of a file.""" - - selector_type = "file" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept - vol.Required("accept"): str, - } - ) - - def __init__(self, config: FileSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> str: - """Validate the passed selection.""" - if not isinstance(data, str): - raise vol.Invalid("Value should be a string") - - UUID(data) - - return data - - dumper.add_representer( Selector, lambda dumper, value: dumper.represent_odict( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 51d9c97ceeb..f9c846c60fa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -7,21 +7,19 @@ from collections.abc import Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial +import inspect import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast, override import voluptuous as vol from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL from homeassistant.const import ( - ATTR_AREA_ID, - ATTR_DEVICE_ID, ATTR_ENTITY_ID, - ATTR_FLOOR_ID, - ATTR_LABEL_ID, CONF_ACTION, CONF_ENTITY_ID, + CONF_SELECTOR, CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, CONF_SERVICE_TEMPLATE, @@ -54,16 +52,15 @@ from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( - area_registry, config_validation as cv, device_registry, entity_registry, - floor_registry, - label_registry, + selector, + target as target_helpers, template, translation, ) -from .group import expand_entity_ids +from .deprecation import deprecated_class, deprecated_function from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType @@ -86,6 +83,7 @@ ALL_SERVICE_DESCRIPTIONS_CACHE: HassKey[ def _base_components() -> dict[str, ModuleType]: """Return a cached lookup of base components.""" from homeassistant.components import ( # noqa: PLC0415 + ai_task, alarm_control_panel, assist_satellite, calendar, @@ -107,6 +105,7 @@ def _base_components() -> dict[str, ModuleType]: ) return { + "ai_task": ai_task, "alarm_control_panel": alarm_control_panel, "assist_satellite": assist_satellite, "calendar": calendar, @@ -169,6 +168,7 @@ def validate_supported_feature(supported_feature: str) -> Any: # to their values. Full validation is done by hassfest.services _FIELD_SCHEMA = vol.Schema( { + vol.Optional(CONF_SELECTOR): selector.validate_selector, vol.Optional("filter"): { vol.Optional("attribute"): { vol.Required(str): [vol.All(str, validate_attribute_option)], @@ -223,87 +223,31 @@ class ServiceParams(TypedDict): target: dict | None -class ServiceTargetSelector: +@deprecated_class( + "homeassistant.helpers.target.TargetSelectorData", + breaks_in_ha_version="2026.8", +) +class ServiceTargetSelector(target_helpers.TargetSelectorData): """Class to hold a target selector for a service.""" - __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") - def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" - service_call_data = service_call.data - entity_ids: str | list | None = service_call_data.get(ATTR_ENTITY_ID) - device_ids: str | list | None = service_call_data.get(ATTR_DEVICE_ID) - area_ids: str | list | None = service_call_data.get(ATTR_AREA_ID) - floor_ids: str | list | None = service_call_data.get(ATTR_FLOOR_ID) - label_ids: str | list | None = service_call_data.get(ATTR_LABEL_ID) - - self.entity_ids = ( - set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set() - ) - self.device_ids = ( - set(cv.ensure_list(device_ids)) if _has_match(device_ids) else set() - ) - self.area_ids = set(cv.ensure_list(area_ids)) if _has_match(area_ids) else set() - self.floor_ids = ( - set(cv.ensure_list(floor_ids)) if _has_match(floor_ids) else set() - ) - self.label_ids = ( - set(cv.ensure_list(label_ids)) if _has_match(label_ids) else set() - ) - - @property - def has_any_selector(self) -> bool: - """Determine if any selectors are present.""" - return bool( - self.entity_ids - or self.device_ids - or self.area_ids - or self.floor_ids - or self.label_ids - ) + super().__init__(service_call.data) -@dataclasses.dataclass(slots=True) -class SelectedEntities: +@deprecated_class( + "homeassistant.helpers.target.SelectedEntities", + breaks_in_ha_version="2026.8", +) +class SelectedEntities(target_helpers.SelectedEntities): """Class to hold the selected entities.""" - # Entities that were explicitly mentioned. - referenced: set[str] = dataclasses.field(default_factory=set) - - # Entities that were referenced via device/area/floor/label ID. - # Should not trigger a warning when they don't exist. - indirectly_referenced: set[str] = dataclasses.field(default_factory=set) - - # Referenced items that could not be found. - missing_devices: set[str] = dataclasses.field(default_factory=set) - missing_areas: set[str] = dataclasses.field(default_factory=set) - missing_floors: set[str] = dataclasses.field(default_factory=set) - missing_labels: set[str] = dataclasses.field(default_factory=set) - - # Referenced devices - referenced_devices: set[str] = dataclasses.field(default_factory=set) - referenced_areas: set[str] = dataclasses.field(default_factory=set) - - def log_missing(self, missing_entities: set[str]) -> None: + @override + def log_missing( + self, missing_entities: set[str], logger: logging.Logger | None = None + ) -> None: """Log about missing items.""" - parts = [] - for label, items in ( - ("floors", self.missing_floors), - ("areas", self.missing_areas), - ("devices", self.missing_devices), - ("entities", missing_entities), - ("labels", self.missing_labels), - ): - if items: - parts.append(f"{label} {', '.join(sorted(items))}") - - if not parts: - return - - _LOGGER.warning( - "Referenced %s are missing or not currently available", - ", ".join(parts), - ) + super().log_missing(missing_entities, logger or _LOGGER) @bind_hass @@ -464,7 +408,10 @@ async def async_extract_entities[_EntityT: Entity]( if data_ent_id == ENTITY_MATCH_ALL: return [entity for entity in entities if entity.available] - referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) + selector_data = target_helpers.TargetSelectorData(service_call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group + ) combined = referenced.referenced | referenced.indirectly_referenced found = [] @@ -480,7 +427,7 @@ async def async_extract_entities[_EntityT: Entity]( found.append(entity) - referenced.log_missing(referenced.referenced & combined) + referenced.log_missing(referenced.referenced & combined, _LOGGER) return found @@ -493,141 +440,27 @@ async def async_extract_entity_ids( Will convert group entity ids to the entity ids it represents. """ - referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) + selector_data = target_helpers.TargetSelectorData(service_call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group + ) return referenced.referenced | referenced.indirectly_referenced -def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: - """Check if ids can match anything.""" - return ids not in (None, ENTITY_MATCH_NONE) - - +@deprecated_function( + "homeassistant.helpers.target.async_extract_referenced_entity_ids", + breaks_in_ha_version="2026.8", +) @bind_hass def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: """Extract referenced entity IDs from a service call.""" - selector = ServiceTargetSelector(service_call) - selected = SelectedEntities() - - if not selector.has_any_selector: - return selected - - entity_ids: set[str] | list[str] = selector.entity_ids - if expand_group: - entity_ids = expand_entity_ids(hass, entity_ids) - - selected.referenced.update(entity_ids) - - if ( - not selector.device_ids - and not selector.area_ids - and not selector.floor_ids - and not selector.label_ids - ): - return selected - - entities = entity_registry.async_get(hass).entities - dev_reg = device_registry.async_get(hass) - area_reg = area_registry.async_get(hass) - - if selector.floor_ids: - floor_reg = floor_registry.async_get(hass) - for floor_id in selector.floor_ids: - if floor_id not in floor_reg.floors: - selected.missing_floors.add(floor_id) - - for area_id in selector.area_ids: - if area_id not in area_reg.areas: - selected.missing_areas.add(area_id) - - for device_id in selector.device_ids: - if device_id not in dev_reg.devices: - selected.missing_devices.add(device_id) - - if selector.label_ids: - label_reg = label_registry.async_get(hass) - for label_id in selector.label_ids: - if label_id not in label_reg.labels: - selected.missing_labels.add(label_id) - - for entity_entry in entities.get_entries_for_label(label_id): - if ( - entity_entry.entity_category is None - and entity_entry.hidden_by is None - ): - selected.indirectly_referenced.add(entity_entry.entity_id) - - for device_entry in dev_reg.devices.get_devices_for_label(label_id): - selected.referenced_devices.add(device_entry.id) - - for area_entry in area_reg.areas.get_areas_for_label(label_id): - selected.referenced_areas.add(area_entry.id) - - # Find areas for targeted floors - if selector.floor_ids: - selected.referenced_areas.update( - area_entry.id - for floor_id in selector.floor_ids - for area_entry in area_reg.areas.get_areas_for_floor(floor_id) - ) - - selected.referenced_areas.update(selector.area_ids) - selected.referenced_devices.update(selector.device_ids) - - if not selected.referenced_areas and not selected.referenced_devices: - return selected - - # Add indirectly referenced by device - selected.indirectly_referenced.update( - entry.entity_id - for device_id in selected.referenced_devices - for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if (entry.entity_category is None and entry.hidden_by is None) + selector_data = target_helpers.TargetSelectorData(service_call.data) + selected = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group ) - - # Find devices for targeted areas - referenced_devices_by_area: set[str] = set() - if selected.referenced_areas: - for area_id in selected.referenced_areas: - referenced_devices_by_area.update( - device_entry.id - for device_entry in dev_reg.devices.get_devices_for_area_id(area_id) - ) - selected.referenced_devices.update(referenced_devices_by_area) - - # Add indirectly referenced by area - selected.indirectly_referenced.update( - entry.entity_id - for area_id in selected.referenced_areas - # The entity's area matches a targeted area - for entry in entities.get_entries_for_area_id(area_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if entry.entity_category is None and entry.hidden_by is None - ) - # Add indirectly referenced by area through device - selected.indirectly_referenced.update( - entry.entity_id - for device_id in referenced_devices_by_area - for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if ( - entry.entity_category is None - and entry.hidden_by is None - and ( - # The entity's device matches a device referenced - # by an area and the entity - # has no explicitly set area - not entry.area_id - ) - ) - ) - - return selected + return SelectedEntities(**dataclasses.asdict(selected)) @bind_hass @@ -635,7 +468,10 @@ async def async_extract_config_entry_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> set[str]: """Extract referenced config entry ids from a service call.""" - referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) + selector_data = target_helpers.TargetSelectorData(service_call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group + ) ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) config_entry_ids: set[str] = set() @@ -946,11 +782,14 @@ async def entity_service_call( target_all_entities = call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL if target_all_entities: - referenced: SelectedEntities | None = None + referenced: target_helpers.SelectedEntities | None = None all_referenced: set[str] | None = None else: # A set of entities we're trying to target. - referenced = async_extract_referenced_entity_ids(hass, call, True) + selector_data = target_helpers.TargetSelectorData(call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, True + ) all_referenced = referenced.referenced | referenced.indirectly_referenced # If the service function is a string, we'll pass it the service call data @@ -975,7 +814,7 @@ async def entity_service_call( missing = referenced.referenced.copy() for entity in entity_candidates: missing.discard(entity.entity_id) - referenced.log_missing(missing) + referenced.log_missing(missing, _LOGGER) entities: list[Entity] = [] for entity in entity_candidates: @@ -1162,7 +1001,7 @@ def verify_domain_control( service_handler: Callable[[ServiceCall], Any], ) -> Callable[[ServiceCall], Any]: """Decorate.""" - if not asyncio.iscoroutinefunction(service_handler): + if not inspect.iscoroutinefunction(service_handler): raise HomeAssistantError("Can only decorate async functions.") async def check_permissions(call: ServiceCall) -> Any: diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py index 47479a53a8a..d46c7a59004 100644 --- a/homeassistant/helpers/service_info/dhcp.py +++ b/homeassistant/helpers/service_info/dhcp.py @@ -12,3 +12,9 @@ class DhcpServiceInfo(BaseServiceInfo): ip: str hostname: str macaddress: str + """The MAC address of the device. + + Please note that for historical reason the DHCP service will always format it + as a lowercase string without colons. + eg. "AA:BB:CC:12:34:56" is stored as "aabbcc123456" + """ diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 075fc50b49a..dac2e5832f6 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine import functools +import inspect from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant @@ -47,7 +48,7 @@ def singleton[_S, _T, _U]( def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py new file mode 100644 index 00000000000..5286daaeef0 --- /dev/null +++ b/homeassistant/helpers/target.py @@ -0,0 +1,364 @@ +"""Helpers for dealing with entity targets.""" + +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +import logging +from logging import Logger +from typing import Any, TypeGuard + +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, + ENTITY_MATCH_NONE, +) +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError + +from . import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + group, + label_registry as lr, +) +from .event import async_track_state_change_event +from .typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass(slots=True, frozen=True) +class TargetStateChangedData: + """Data for state change events related to targets.""" + + state_change_event: Event[EventStateChangedData] + targeted_entity_ids: set[str] + + +def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: + """Check if ids can match anything.""" + return ids not in (None, ENTITY_MATCH_NONE) + + +class TargetSelectorData: + """Class to hold data of target selector.""" + + __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") + + def __init__(self, config: ConfigType) -> None: + """Extract ids from the config.""" + entity_ids: str | list | None = config.get(ATTR_ENTITY_ID) + device_ids: str | list | None = config.get(ATTR_DEVICE_ID) + area_ids: str | list | None = config.get(ATTR_AREA_ID) + floor_ids: str | list | None = config.get(ATTR_FLOOR_ID) + label_ids: str | list | None = config.get(ATTR_LABEL_ID) + + self.entity_ids = ( + set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set() + ) + self.device_ids = ( + set(cv.ensure_list(device_ids)) if _has_match(device_ids) else set() + ) + self.area_ids = set(cv.ensure_list(area_ids)) if _has_match(area_ids) else set() + self.floor_ids = ( + set(cv.ensure_list(floor_ids)) if _has_match(floor_ids) else set() + ) + self.label_ids = ( + set(cv.ensure_list(label_ids)) if _has_match(label_ids) else set() + ) + + @property + def has_any_selector(self) -> bool: + """Determine if any selectors are present.""" + return bool( + self.entity_ids + or self.device_ids + or self.area_ids + or self.floor_ids + or self.label_ids + ) + + +@dataclasses.dataclass(slots=True) +class SelectedEntities: + """Class to hold the selected entities.""" + + # Entities that were explicitly mentioned. + referenced: set[str] = dataclasses.field(default_factory=set) + + # Entities that were referenced via device/area/floor/label ID. + # Should not trigger a warning when they don't exist. + indirectly_referenced: set[str] = dataclasses.field(default_factory=set) + + # Referenced items that could not be found. + missing_devices: set[str] = dataclasses.field(default_factory=set) + missing_areas: set[str] = dataclasses.field(default_factory=set) + missing_floors: set[str] = dataclasses.field(default_factory=set) + missing_labels: set[str] = dataclasses.field(default_factory=set) + + referenced_devices: set[str] = dataclasses.field(default_factory=set) + referenced_areas: set[str] = dataclasses.field(default_factory=set) + + def log_missing(self, missing_entities: set[str], logger: Logger) -> None: + """Log about missing items.""" + parts = [] + for label, items in ( + ("floors", self.missing_floors), + ("areas", self.missing_areas), + ("devices", self.missing_devices), + ("entities", missing_entities), + ("labels", self.missing_labels), + ): + if items: + parts.append(f"{label} {', '.join(sorted(items))}") + + if not parts: + return + + logger.warning( + "Referenced %s are missing or not currently available", + ", ".join(parts), + ) + + +def async_extract_referenced_entity_ids( + hass: HomeAssistant, selector_data: TargetSelectorData, expand_group: bool = True +) -> SelectedEntities: + """Extract referenced entity IDs from a target selector.""" + selected = SelectedEntities() + + if not selector_data.has_any_selector: + return selected + + entity_ids: set[str] | list[str] = selector_data.entity_ids + if expand_group: + entity_ids = group.expand_entity_ids(hass, entity_ids) + + selected.referenced.update(entity_ids) + + if ( + not selector_data.device_ids + and not selector_data.area_ids + and not selector_data.floor_ids + and not selector_data.label_ids + ): + return selected + + entities = er.async_get(hass).entities + dev_reg = dr.async_get(hass) + area_reg = ar.async_get(hass) + + if selector_data.floor_ids: + floor_reg = fr.async_get(hass) + for floor_id in selector_data.floor_ids: + if floor_id not in floor_reg.floors: + selected.missing_floors.add(floor_id) + + for area_id in selector_data.area_ids: + if area_id not in area_reg.areas: + selected.missing_areas.add(area_id) + + for device_id in selector_data.device_ids: + if device_id not in dev_reg.devices: + selected.missing_devices.add(device_id) + + if selector_data.label_ids: + label_reg = lr.async_get(hass) + for label_id in selector_data.label_ids: + if label_id not in label_reg.labels: + selected.missing_labels.add(label_id) + + for entity_entry in entities.get_entries_for_label(label_id): + if ( + entity_entry.entity_category is None + and entity_entry.hidden_by is None + ): + selected.indirectly_referenced.add(entity_entry.entity_id) + + for device_entry in dev_reg.devices.get_devices_for_label(label_id): + selected.referenced_devices.add(device_entry.id) + + for area_entry in area_reg.areas.get_areas_for_label(label_id): + selected.referenced_areas.add(area_entry.id) + + # Find areas for targeted floors + if selector_data.floor_ids: + selected.referenced_areas.update( + area_entry.id + for floor_id in selector_data.floor_ids + for area_entry in area_reg.areas.get_areas_for_floor(floor_id) + ) + + selected.referenced_areas.update(selector_data.area_ids) + selected.referenced_devices.update(selector_data.device_ids) + + if not selected.referenced_areas and not selected.referenced_devices: + return selected + + # Add indirectly referenced by device + selected.indirectly_referenced.update( + entry.entity_id + for device_id in selected.referenced_devices + for entry in entities.get_entries_for_device_id(device_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if (entry.entity_category is None and entry.hidden_by is None) + ) + + # Find devices for targeted areas + referenced_devices_by_area: set[str] = set() + if selected.referenced_areas: + for area_id in selected.referenced_areas: + referenced_devices_by_area.update( + device_entry.id + for device_entry in dev_reg.devices.get_devices_for_area_id(area_id) + ) + selected.referenced_devices.update(referenced_devices_by_area) + + # Add indirectly referenced by area + selected.indirectly_referenced.update( + entry.entity_id + for area_id in selected.referenced_areas + # The entity's area matches a targeted area + for entry in entities.get_entries_for_area_id(area_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if entry.entity_category is None and entry.hidden_by is None + ) + # Add indirectly referenced by area through device + selected.indirectly_referenced.update( + entry.entity_id + for device_id in referenced_devices_by_area + for entry in entities.get_entries_for_device_id(device_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if ( + entry.entity_category is None + and entry.hidden_by is None + and ( + # The entity's device matches a device referenced + # by an area and the entity + # has no explicitly set area + not entry.area_id + ) + ) + ) + + return selected + + +class TargetStateChangeTracker: + """Helper class to manage state change tracking for targets.""" + + def __init__( + self, + hass: HomeAssistant, + selector_data: TargetSelectorData, + action: Callable[[TargetStateChangedData], Any], + entity_filter: Callable[[set[str]], set[str]], + ) -> None: + """Initialize the state change tracker.""" + self._hass = hass + self._selector_data = selector_data + self._action = action + self._entity_filter = entity_filter + + self._state_change_unsub: CALLBACK_TYPE | None = None + self._registry_unsubs: list[CALLBACK_TYPE] = [] + + def async_setup(self) -> Callable[[], None]: + """Set up the state change tracking.""" + self._setup_registry_listeners() + self._track_entities_state_change() + return self._unsubscribe + + def _track_entities_state_change(self) -> None: + """Set up state change tracking for currently selected entities.""" + selected = async_extract_referenced_entity_ids( + self._hass, self._selector_data, expand_group=False + ) + + tracked_entities = self._entity_filter( + selected.referenced.union(selected.indirectly_referenced) + ) + + @callback + def state_change_listener(event: Event[EventStateChangedData]) -> None: + """Handle state change events.""" + if ( + event.data["entity_id"] in selected.referenced + or event.data["entity_id"] in selected.indirectly_referenced + ): + self._action(TargetStateChangedData(event, tracked_entities)) + + _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) + self._state_change_unsub = async_track_state_change_event( + self._hass, tracked_entities, state_change_listener + ) + + def _setup_registry_listeners(self) -> None: + """Set up listeners for registry changes that require resubscription.""" + + @callback + def resubscribe_state_change_event(event: Event[Any] | None = None) -> None: + """Resubscribe to state change events when registry changes.""" + if self._state_change_unsub: + self._state_change_unsub() + self._track_entities_state_change() + + # Subscribe to registry updates that can change the entities to track: + # - Entity registry: entity added/removed; entity labels changed; entity area changed. + # - Device registry: device labels changed; device area changed. + # - Area registry: area floor changed. + # + # We don't track other registries (like floor or label registries) because their + # changes don't affect which entities are tracked. + self._registry_unsubs = [ + self._hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + ar.EVENT_AREA_REGISTRY_UPDATED, resubscribe_state_change_event + ), + ] + + def _unsubscribe(self) -> None: + """Unsubscribe from all events.""" + for registry_unsub in self._registry_unsubs: + registry_unsub() + self._registry_unsubs.clear() + if self._state_change_unsub: + self._state_change_unsub() + self._state_change_unsub = None + + +def async_track_target_selector_state_change_event( + hass: HomeAssistant, + target_selector_config: ConfigType, + action: Callable[[TargetStateChangedData], Any], + entity_filter: Callable[[set[str]], set[str]] = lambda x: x, +) -> CALLBACK_TYPE: + """Track state changes for entities referenced directly or indirectly in a target selector.""" + selector_data = TargetSelectorData(target_selector_config) + if not selector_data.has_any_selector: + raise HomeAssistantError( + f"Target selector {target_selector_config} does not have any selectors defined" + ) + tracker = TargetStateChangeTracker(hass, selector_data, action, entity_filter) + return tracker.async_setup() diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 85ee1e28309..8e3106093aa 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2030,7 +2030,7 @@ def apply(value, fn, *args, **kwargs): def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: """Turn a macro with a 'returns' keyword argument into a function that returns what that argument is called with.""" - def wrapper(value, *args, **kwargs): + def wrapper(*args, **kwargs): return_value = None def returns(value): @@ -2039,7 +2039,7 @@ def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: return value # Call the callable with the value and other args - macro(value, *args, **kwargs, returns=returns) + macro(*args, **kwargs, returns=returns) return return_value # Remove "macro_" from the macro's name to avoid confusion in the wrapper's name diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 853b5aaf812..741fac3fcf7 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -5,11 +5,12 @@ from __future__ import annotations import abc import asyncio from collections import defaultdict -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import functools +import inspect import logging -from typing import Any, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast import voluptuous as vol @@ -18,6 +19,7 @@ from homeassistant.const import ( CONF_ENABLED, CONF_ID, CONF_PLATFORM, + CONF_SELECTOR, CONF_VARIABLES, ) from homeassistant.core import ( @@ -29,13 +31,25 @@ from homeassistant.core import ( is_callback, ) from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_integration, + async_get_integrations, +) from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey +from homeassistant.util.yaml import load_yaml_dict +from . import config_validation as cv, selector +from .automation import get_absolute_description_key, get_relative_description_key +from .integration_platform import async_process_integration_platforms +from .selector import TargetSelector from .template import Template from .typing import ConfigType, TemplateVarsType +_LOGGER = logging.getLogger(__name__) + _PLATFORM_ALIASES = { "device": "device_automation", "event": "homeassistant", @@ -49,6 +63,107 @@ DATA_PLUGGABLE_ACTIONS: HassKey[defaultdict[tuple, PluggableActionsEntry]] = Has "pluggable_actions" ) +TRIGGER_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey( + "trigger_description_cache" +) +TRIGGER_PLATFORM_SUBSCRIPTIONS: HassKey[ + list[Callable[[set[str]], Coroutine[Any, Any, None]]] +] = HassKey("trigger_platform_subscriptions") +TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers") + + +# Basic schemas to sanity check the trigger descriptions, +# full validation is done by hassfest.triggers +_FIELD_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SELECTOR): selector.validate_selector, + }, + extra=vol.ALLOW_EXTRA, +) + +_TRIGGER_SCHEMA = vol.Schema( + { + vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + + +def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise vol.Invalid("Key does not start with .") + return key + + +_TRIGGERS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, starts_with_dot)): object, + cv.underscore_slug: vol.Any(None, _TRIGGER_SCHEMA), + } +) + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the trigger helper.""" + hass.data[TRIGGER_DESCRIPTION_CACHE] = {} + hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] = [] + hass.data[TRIGGERS] = {} + await async_process_integration_platforms( + hass, "trigger", _register_trigger_platform, wait_for_platforms=True + ) + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[set[str]], Coroutine[Any, Any, None]], +) -> Callable[[], None]: + """Subscribe to trigger platform events.""" + trigger_platform_event_subscriptions = hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] + + def remove_subscription() -> None: + trigger_platform_event_subscriptions.remove(on_event) + + trigger_platform_event_subscriptions.append(on_event) + return remove_subscription + + +async def _register_trigger_platform( + hass: HomeAssistant, integration_domain: str, platform: TriggerProtocol +) -> None: + """Register a trigger platform.""" + + new_triggers: set[str] = set() + + if hasattr(platform, "async_get_triggers"): + for trigger_key in await platform.async_get_triggers(hass): + trigger_key = get_absolute_description_key(integration_domain, trigger_key) + hass.data[TRIGGERS][trigger_key] = integration_domain + new_triggers.add(trigger_key) + elif hasattr(platform, "async_validate_trigger_config") or hasattr( + platform, "TRIGGER_SCHEMA" + ): + hass.data[TRIGGERS][integration_domain] = integration_domain + new_triggers.add(integration_domain) + else: + _LOGGER.debug( + "Integration %s does not provide trigger support, skipping", + integration_domain, + ) + return + + # We don't use gather here because gather adds additional overhead + # when wrapping each coroutine in a task, and we expect our listeners + # to call trigger.async_get_all_descriptions which will only yield + # the first time it's called, after that it returns cached data. + for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]: + try: + await listener(new_triggers) + except Exception: + _LOGGER.exception("Error while notifying trigger platform listener") + class Trigger(abc.ABC): """Trigger class.""" @@ -58,18 +173,18 @@ class Trigger(abc.ABC): @classmethod @abc.abstractmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @abc.abstractmethod - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: - """Attach a trigger.""" + """Attach the trigger.""" class TriggerProtocol(Protocol): @@ -243,9 +358,8 @@ class PluggableAction: async def _async_get_trigger_platform( - hass: HomeAssistant, config: ConfigType -) -> TriggerProtocol: - trigger_key: str = config[CONF_PLATFORM] + hass: HomeAssistant, trigger_key: str +) -> tuple[str, TriggerProtocol]: platform_and_sub_type = trigger_key.split(".") platform = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) @@ -254,7 +368,7 @@ async def _async_get_trigger_platform( except IntegrationNotFound: raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None try: - return await integration.async_get_platform("trigger") + return platform, await integration.async_get_platform("trigger") except ImportError: raise vol.Invalid( f"Integration '{platform}' does not provide trigger support" @@ -267,13 +381,16 @@ async def async_validate_trigger_config( """Validate triggers.""" config = [] for conf in trigger_config: - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger_key: str = conf[CONF_PLATFORM] - if not (trigger := trigger_descriptors.get(trigger_key)): + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + if not (trigger := trigger_descriptors.get(relative_trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") - conf = await trigger.async_validate_trigger_config(hass, conf) + conf = await trigger.async_validate_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): conf = await platform.async_validate_trigger_config(hass, conf) else: @@ -299,7 +416,7 @@ def _trigger_action_wrapper( check_func = check_func.func wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] - if asyncio.iscoroutinefunction(check_func): + if inspect.iscoroutinefunction(check_func): async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) @functools.wraps(async_action) @@ -357,7 +474,8 @@ async def async_initialize_triggers( if not enabled: continue - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) trigger_id = conf.get(CONF_ID, f"{idx}") trigger_idx = f"{idx}" trigger_alias = conf.get(CONF_ALIAS) @@ -373,8 +491,11 @@ async def async_initialize_triggers( action_wrapper = _trigger_action_wrapper(hass, action, conf) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger = trigger_descriptors[conf[CONF_PLATFORM]](hass, conf) - coro = trigger.async_attach_trigger(action_wrapper, info) + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + trigger = trigger_descriptors[relative_trigger_key](hass, conf) + coro = trigger.async_attach(action_wrapper, info) else: coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) @@ -409,3 +530,110 @@ async def async_initialize_triggers( remove() return remove_triggers + + +def _load_triggers_file(integration: Integration) -> dict[str, Any]: + """Load triggers file for an integration.""" + try: + return cast( + dict[str, Any], + _TRIGGERS_SCHEMA( + load_yaml_dict(str(integration.file_path / "triggers.yaml")) + ), + ) + except FileNotFoundError: + _LOGGER.warning( + "Unable to find triggers.yaml for the %s integration", integration.domain + ) + return {} + except (HomeAssistantError, vol.Invalid) as ex: + _LOGGER.warning( + "Unable to parse triggers.yaml for the %s integration: %s", + integration.domain, + ex, + ) + return {} + + +def _load_triggers_files( + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: + """Load trigger files for multiple integrations.""" + return { + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_triggers_file(integration).items() + } + for integration in integrations + } + + +async def async_get_all_descriptions( + hass: HomeAssistant, +) -> dict[str, dict[str, Any] | None]: + """Return descriptions (i.e. user documentation) for all triggers.""" + descriptions_cache = hass.data[TRIGGER_DESCRIPTION_CACHE] + + triggers = hass.data[TRIGGERS] + # See if there are new triggers not seen before. + # Any trigger that we saw before already has an entry in description_cache. + all_triggers = set(triggers) + previous_all_triggers = set(descriptions_cache) + # If the triggers are the same, we can return the cache + if previous_all_triggers == all_triggers: + return descriptions_cache + + # Files we loaded for missing descriptions + new_triggers_descriptions: dict[str, dict[str, Any]] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new triggers get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + triggers = triggers.copy() + + if missing_triggers := all_triggers.difference(descriptions_cache): + domains_with_missing_triggers = { + triggers[missing_trigger] for missing_trigger in missing_triggers + } + ints_or_excs = await async_get_integrations(hass, domains_with_missing_triggers) + integrations: list[Integration] = [] + for domain, int_or_exc in ints_or_excs.items(): + if type(int_or_exc) is Integration and int_or_exc.has_triggers: + integrations.append(int_or_exc) + continue + if TYPE_CHECKING: + assert isinstance(int_or_exc, Exception) + _LOGGER.debug( + "Failed to load triggers.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) + + if integrations: + new_triggers_descriptions = await hass.async_add_executor_job( + _load_triggers_files, integrations + ) + + # Make a copy of the old cache and add missing descriptions to it + new_descriptions_cache = descriptions_cache.copy() + for missing_trigger in missing_triggers: + domain = triggers[missing_trigger] + + if ( + yaml_description := new_triggers_descriptions.get(domain, {}).get( + missing_trigger + ) + ) is None: + _LOGGER.debug( + "No trigger descriptions found for trigger %s, skipping", + missing_trigger, + ) + new_descriptions_cache[missing_trigger] = None + continue + + description = {"fields": yaml_description.get("fields", {})} + + new_descriptions_cache[missing_trigger] = description + + hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache + return new_descriptions_cache diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index bf7598eb024..d8ebab8b83e 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -13,8 +13,10 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, + SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, @@ -389,3 +391,20 @@ class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): ManualTriggerEntity.__init__(self, hass, config) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = config.get(CONF_STATE_CLASS) + + @callback + def _set_native_value_with_possible_timestamp(self, value: Any) -> None: + """Set native value with possible timestamp. + + If self.device_class is `date` or `timestamp`, + it will try to parse the value to a date/datetime object. + """ + if self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + self._attr_native_value = value + elif value is not None: + self._attr_native_value = async_parse_date_datetime( + value, self.entity_id, self.device_class + ) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index bd85391f98f..16f3b9b6964 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -84,9 +84,19 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.update_interval = update_interval self._shutdown_requested = False if config_entry is UNDEFINED: + # late import to avoid circular imports + from . import frame # noqa: PLC0415 + + # It is not planned to enforce this for custom integrations. + # see https://github.com/home-assistant/core/pull/138161#discussion_r1958184241 + frame.report_usage( + "relies on ContextVar, but should pass the config entry explicitly.", + core_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.IGNORE, + breaks_in_ha_version="2026.8", + ) + self.config_entry = config_entries.current_entry.get() - # This should be deprecated once all core integrations are updated - # to pass in the config entry explicitly. else: self.config_entry = config_entry self.always_update = always_update diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6a3061b0d2a..07c4a934573 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -10,7 +10,6 @@ import asyncio from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass -import functools as ft import importlib import logging import os @@ -67,6 +66,7 @@ _LOGGER = logging.getLogger(__name__) # BASE_PRELOAD_PLATFORMS = [ "backup", + "condition", "config", "config_flow", "diagnostics", @@ -858,15 +858,25 @@ class Integration: return self.manifest.get("import_executor", True) @cached_property - def has_translations(self) -> bool: - """Return if the integration has translations.""" - return "translations" in self._top_level_files + def has_conditions(self) -> bool: + """Return if the integration has conditions.""" + return "conditions.yaml" in self._top_level_files @cached_property def has_services(self) -> bool: """Return if the integration has services.""" return "services.yaml" in self._top_level_files + @cached_property + def has_translations(self) -> bool: + """Return if the integration has translations.""" + return "translations" in self._top_level_files + + @cached_property + def has_triggers(self) -> bool: + """Return if the integration has triggers.""" + return "triggers.yaml" in self._top_level_files + @property def mqtt(self) -> list[str] | None: """Return Integration MQTT entries.""" @@ -1639,77 +1649,6 @@ class CircularDependency(LoaderError): self.args[1].insert(0, domain) -def _load_file( - hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ComponentProtocol | None: - """Try to load specified file. - - Looks in config dir first, then built-in components. - Only returns it if also found to be valid. - Async friendly. - """ - cache = hass.data[DATA_COMPONENTS] - if module := cache.get(comp_or_platform): - return cast(ComponentProtocol, module) - - for path in (f"{base}.{comp_or_platform}" for base in base_paths): - try: - module = importlib.import_module(path) - - # In Python 3 you can import files from directories that do not - # contain the file __init__.py. A directory is a valid module if - # it contains a file with the .py extension. In this case Python - # will succeed in importing the directory as a module and call it - # a namespace. We do not care about namespaces. - # This prevents that when only - # custom_components/switch/some_platform.py exists, - # the import custom_components.switch would succeed. - # __file__ was unset for namespaces before Python 3.7 - if getattr(module, "__file__", None) is None: - continue - - cache[comp_or_platform] = module - - return cast(ComponentProtocol, module) - - except ImportError as err: - # This error happens if for example custom_components/switch - # exists and we try to load switch.demo. - # Ignore errors for custom_components, custom_components.switch - # and custom_components.switch.demo. - white_listed_errors = [] - parts = [] - for part in path.split("."): - parts.append(part) - white_listed_errors.append(f"No module named '{'.'.join(parts)}'") - - if str(err) not in white_listed_errors: - _LOGGER.exception( - "Error loading %s. Make sure all dependencies are installed", path - ) - - return None - - -class ModuleWrapper: - """Class to wrap a Python module and auto fill in hass argument.""" - - def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: - """Initialize the module wrapper.""" - self._hass = hass - self._module = module - - def __getattr__(self, attr: str) -> Any: - """Fetch an attribute.""" - value = getattr(self._module, attr) - - if hasattr(value, "__bind_hass"): - value = ft.partial(value, self._hass) - - setattr(self, attr, value) - return value - - def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. @@ -1733,13 +1672,6 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: sys.path_importer_cache.pop(hass.config.config_dir, None) -def _lookup_path(hass: HomeAssistant) -> list[str]: - """Return the lookup paths for legacy lookups.""" - if hass.config.recovery_mode or hass.config.safe_mode: - return [PACKAGE_BUILTIN] - return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] - - def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: """Test if a component module is loaded.""" return module in hass.data[DATA_COMPONENTS] @@ -1784,6 +1716,13 @@ def async_get_issue_tracker( # If we know nothing about the integration, suggest opening an issue on HA core return issue_tracker + if module and not integration_domain: + # If we only have a module, we can try to get the integration domain from it + if module.startswith("custom_components."): + integration_domain = module.split(".")[1] + elif module.startswith("homeassistant.components."): + integration_domain = module.split(".")[2] + if not integration: integration = async_get_issue_integration(hass, integration_domain) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d4fd42df379..6872fec3362 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,54 +1,54 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.2.0 -aiodiscover==2.7.0 +aiodhcpwatcher==1.2.1 +aiodiscover==2.7.1 aiodns==3.5.0 -aiohasupervisor==0.3.1 +aiohasupervisor==0.3.2b0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.13 +aiohttp==3.12.15 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 atomicwrites-homeassistant==1.4.1 attrs==25.3.0 audioop-lts==0.2.1 av==13.1.0 awesomeversion==25.5.0 bcrypt==4.3.0 -bleak-retry-connector==3.9.0 -bleak==0.22.3 -bluetooth-adapters==0.21.4 +bleak-retry-connector==4.0.2 +bleak==1.0.1 +bluetooth-adapters==2.0.0 bluetooth-auto-recovery==1.5.2 -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==45.0.3 -dbus-fast==2.43.0 +dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==3.49.0 -hass-nabucasa==0.103.0 -hassil==2.2.3 +habluetooth==5.0.1 +hass-nabucasa==1.0.0 +hassil==3.1.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.4 -home-assistant-intents==2025.6.23 +home-assistant-frontend==20250811.0 +home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.18 +orjson==3.11.2 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==11.2.1 +Pillow==11.3.0 propcache==0.3.2 psutil-home-assistant==0.0.1 PyJWT==2.10.1 @@ -68,9 +68,9 @@ standard-telnetlib==3.13.0 typing-extensions>=4.14.0,<5.0 ulid-transform==1.4.0 urllib3>=2.0 -uv==0.7.1 +uv==0.8.9 voluptuous-openapi==0.1.0 -voluptuous-serialize==2.6.0 +voluptuous-serialize==2.7.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.20.1 @@ -118,7 +118,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.0 +numpy==2.3.2 pandas==2.3.0 # Constrain multidict to avoid typing issues @@ -144,18 +144,14 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.31.1 +protobuf==6.32.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python @@ -172,7 +168,7 @@ poetry==1000000000.0.0 # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.4.0 +charset-normalizer==3.4.3 # dacite: Ensure we have a version that is able to handle type unions for # NAM, Brother, and GIOS. @@ -213,7 +209,17 @@ aiofiles>=24.1.0 # https://github.com/aio-libs/multidict/issues/1131 multidict>=6.4.2 -# rpds-py > 0.25.0 requires cargo 1.84.0 -# Stable Alpine current only ships cargo 1.83.0 +# rpds-py frequently updates cargo causing build failures # No wheels upstream available for armhf & armv7 -rpds-py==0.24.0 +rpds-py==0.26.0 + +# Constraint num2words to 0.5.14 as 0.5.15 and 0.5.16 were removed from PyPI +num2words==0.5.14 + +# pymodbus does not follow SemVer, and it keeps getting +# downgraded or upgraded by custom components +# This ensures all use the same version +pymodbus==3.11.1 + +# Some packages don't support gql 4.0.0 yet +gql<4.0.0 diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 59775655854..abcf32f2659 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -17,6 +17,7 @@ from . import bootstrap from .core import callback from .helpers.frame import warn_use from .util.executor import InterruptibleThreadPoolExecutor +from .util.resource import set_open_file_descriptor_limit from .util.thread import deadlock_safe_shutdown # @@ -146,6 +147,7 @@ def _enable_posix_spawn() -> None: def run(runtime_config: RuntimeConfig) -> int: """Run Home Assistant.""" _enable_posix_spawn() + set_open_file_descriptor_limit() asyncio.set_event_loop_policy(HassEventLoopPolicy(runtime_config.debug)) # Backport of cpython 3.9 asyncio.run with a _cancel_all_tasks that times out loop = asyncio.new_event_loop() diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 6e47163e90a..8e232498177 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -65,6 +65,7 @@ "path": "Path", "pin": "PIN code", "port": "Port", + "prompt": "Instructions", "ssl": "Uses an SSL certificate", "url": "URL", "usb_path": "USB device path", @@ -128,9 +129,11 @@ "disabled": "Disabled", "discharging": "Discharging", "disconnected": "Disconnected", + "empty": "Empty", "enabled": "Enabled", "error": "Error", "fault": "Fault", + "full": "Full", "high": "High", "home": "Home", "idle": "Idle", diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 19515fd7945..17a4a86f106 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine, Iterable, KeysView, Mapping from datetime import datetime, timedelta from functools import wraps +import inspect import random import re import string @@ -125,7 +125,7 @@ class Throttle: def __call__(self, method: Callable) -> Callable: """Caller for the throttle.""" # Make sure we return a coroutine if the method is async. - if asyncio.iscoroutinefunction(method): + if inspect.iscoroutinefunction(method): async def throttled_value() -> None: """Stand-in function for when real func is being throttled.""" diff --git a/homeassistant/util/resource.py b/homeassistant/util/resource.py new file mode 100644 index 00000000000..41982df9e50 --- /dev/null +++ b/homeassistant/util/resource.py @@ -0,0 +1,65 @@ +"""Resource management utilities for Home Assistant.""" + +from __future__ import annotations + +import logging +import os +import resource +from typing import Final + +_LOGGER = logging.getLogger(__name__) + +# Default soft file descriptor limit to set +DEFAULT_SOFT_FILE_LIMIT: Final = 2048 + + +def set_open_file_descriptor_limit() -> None: + """Set the maximum open file descriptor soft limit.""" + try: + # Check environment variable first, then use default + soft_limit = int(os.environ.get("SOFT_FILE_LIMIT", DEFAULT_SOFT_FILE_LIMIT)) + + # Get current limits + current_soft, current_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + _LOGGER.debug( + "Current file descriptor limits: soft=%d, hard=%d", + current_soft, + current_hard, + ) + + # Don't increase if already at or above the desired limit + if current_soft >= soft_limit: + _LOGGER.debug( + "Current soft limit (%d) is already >= desired limit (%d), skipping", + current_soft, + soft_limit, + ) + return + + # Don't set soft limit higher than hard limit + if soft_limit > current_hard: + _LOGGER.warning( + "Requested soft limit (%d) exceeds hard limit (%d), " + "setting to hard limit", + soft_limit, + current_hard, + ) + soft_limit = current_hard + + # Set the new soft limit + resource.setrlimit(resource.RLIMIT_NOFILE, (soft_limit, current_hard)) + + # Verify the change + new_soft, new_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + _LOGGER.info( + "File descriptor limits updated: soft=%d->%d, hard=%d", + current_soft, + new_soft, + new_hard, + ) + + except OSError as err: + _LOGGER.error("Failed to set file descriptor limit: %s", err) + except ValueError as err: + _LOGGER.error("Invalid file descriptor limit value: %s", err) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index d0830d1f8bb..ad459e55d15 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -7,12 +7,14 @@ from functools import lru_cache from math import floor, log10 from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -27,6 +29,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfReactiveEnergy, + UnitOfReactivePower, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -381,6 +384,20 @@ class MassConverter(BaseUnitConverter): } +class ApparentPowerConverter(BaseUnitConverter): + """Utility to convert apparent power values.""" + + UNIT_CLASS = "apparent_power" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000, + UnitOfApparentPower.VOLT_AMPERE: 1, + } + VALID_UNITS = { + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + } + + class PowerConverter(BaseUnitConverter): """Utility to convert power values.""" @@ -444,6 +461,22 @@ class ReactiveEnergyConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfReactiveEnergy) +class ReactivePowerConverter(BaseUnitConverter): + """Utility to convert reactive power values.""" + + UNIT_CLASS = "reactive_power" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE: 1 * 1000, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE: 1, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE: 1 / 1000, + } + VALID_UNITS = { + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + } + + class SpeedConverter(BaseUnitConverter): """Utility to convert speed values.""" @@ -693,12 +726,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter): UNIT_CLASS = "concentration" _UNIT_CONVERSION: dict[str | None, float] = { - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000.0, # 1000 µg/m³ = 1 mg/m³ - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1.0, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.0, # 1000 µg/m³ = 1 mg/m³ + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³ + CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0, } VALID_UNITS = { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_GRAMS_PER_CUBIC_METER, } diff --git a/mypy.ini b/mypy.ini index a6b673be03b..ad9196c80c5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -285,6 +285,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.airq.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2846,16 +2856,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.linear_garage_door.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.linkplay.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3526,6 +3526,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.open_router.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.openai_conversation.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3566,6 +3576,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.opower.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.oralb.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4396,6 +4416,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sleep_as_android.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sleepiq.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4748,6 +4778,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tankerkoenig.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tautulli.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -5099,6 +5139,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.uptime_kuma.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.uptimerobot.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -5189,6 +5239,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.volvo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_async_load_fixtures.py b/pylint/plugins/hass_async_load_fixtures.py new file mode 100644 index 00000000000..b1680f3f280 --- /dev/null +++ b/pylint/plugins/hass_async_load_fixtures.py @@ -0,0 +1,80 @@ +"""Plugin for logger invocations.""" + +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +FUNCTION_NAMES = ( + "load_fixture", + "load_json_array_fixture", + "load_json_object_fixture", +) + + +class HassLoadFixturesChecker(BaseChecker): + """Checker for I/O load fixtures.""" + + name = "hass_async_load_fixtures" + priority = -1 + msgs = { + "W7481": ( + "Test fixture files should be loaded asynchronously", + "hass-async-load-fixtures", + "Used when a test fixture file is loaded synchronously", + ), + } + options = () + + _decorators_queue: list[nodes.Decorators] + _function_queue: list[nodes.FunctionDef | nodes.AsyncFunctionDef] + _in_test_module: bool + + def visit_module(self, node: nodes.Module) -> None: + """Visit a module definition.""" + self._in_test_module = node.name.startswith("tests.") + self._decorators_queue = [] + self._function_queue = [] + + def visit_decorators(self, node: nodes.Decorators) -> None: + """Visit a function definition.""" + self._decorators_queue.append(node) + + def leave_decorators(self, node: nodes.Decorators) -> None: + """Leave a function definition.""" + self._decorators_queue.pop() + + def visit_functiondef(self, node: nodes.FunctionDef) -> None: + """Visit a function definition.""" + self._function_queue.append(node) + + def leave_functiondef(self, node: nodes.FunctionDef) -> None: + """Leave a function definition.""" + self._function_queue.pop() + + visit_asyncfunctiondef = visit_functiondef + leave_asyncfunctiondef = leave_functiondef + + def visit_call(self, node: nodes.Call) -> None: + """Check for sync I/O in load_fixture.""" + if ( + # Ensure we are in a test module + not self._in_test_module + # Ensure we are in an async function context + or not self._function_queue + or not isinstance(self._function_queue[-1], nodes.AsyncFunctionDef) + # Ensure we are not in the decorators + or self._decorators_queue + # Check function name + or not isinstance(node.func, nodes.Name) + or node.func.name not in FUNCTION_NAMES + ): + return + + self.add_message("hass-async-load-fixtures", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassLoadFixturesChecker(linter)) diff --git a/pylint/plugins/hass_enforce_greek_micro_char.py b/pylint/plugins/hass_enforce_greek_micro_char.py new file mode 100644 index 00000000000..909af66cd9e --- /dev/null +++ b/pylint/plugins/hass_enforce_greek_micro_char.py @@ -0,0 +1,76 @@ +"""Plugin for checking preferred coding of μ is used.""" + +from __future__ import annotations + +from typing import Any + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class HassEnforceGreekMicroCharChecker(BaseChecker): + """Checker for micro char.""" + + name = "hass-enforce-greek-micro-char" + priority = -1 + msgs = { + "W7452": ( + "Constants with a micro unit prefix must encode the " + "small Greek Letter Mu as U+03BC (\u03bc), not as U+00B5 (\u00b5)", + "hass-enforce-greek-micro-char", + "According to [The Unicode Consortium]" + "(https://en.wikipedia.org/wiki/Micro-#Symbol_encoding_in_character_sets)," + " the Greek letter character is preferred. " + "To search a specific encoded μ char in Microsoft Visual Studio Code, " + 'make sure the "Match case" option is enabled. Note that this only works ' + "when searching globally, and not while searching a single document.", + ), + } + options = () + + def visit_annassign(self, node: nodes.AnnAssign) -> None: + """Check for micro char const or StrEnum with type annotations.""" + self._do_micro_check(node.target, node) + + def visit_assign(self, node: nodes.Assign) -> None: + """Check for micro char const without type annotations.""" + for target in node.targets: + self._do_micro_check(target, node) + + def _do_micro_check( + self, target: nodes.NodeNG, node: nodes.Assign | nodes.AnnAssign + ) -> None: + """Check const assignment is not containing ANSI micro char.""" + + def _check_const(node_const: nodes.Const | Any) -> bool: + if ( + isinstance(node_const, nodes.Const) + and isinstance(node_const.value, str) + and "\u00b5" in node_const.value + ): + self.add_message(self.name, node=node) + return True + return False + + # Check constant assignments + if ( + isinstance(target, nodes.AssignName) + and isinstance(node.value, nodes.Const) + and _check_const(node.value) + ): + return + + # Check dict with EntityDescription calls + if isinstance(target, nodes.AssignName) and isinstance(node.value, nodes.Dict): + for _, subnode in node.value.items: + if not isinstance(subnode, nodes.Call): + continue + for keyword in subnode.keywords: + if _check_const(keyword.value): + return + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceGreekMicroCharChecker(linter)) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 32a053527f6..82118209e65 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -3241,7 +3241,7 @@ def _get_module_platform(module_name: str) -> str | None: # Or `homeassistant.components..` return None - platform = module_match.groups()[0] + platform = module_match.group(1) return platform.lstrip(".") if platform else "__init__" diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py index e386986fa23..cc2a40d4a4a 100644 --- a/pylint/plugins/hass_inheritance.py +++ b/pylint/plugins/hass_inheritance.py @@ -18,7 +18,7 @@ def _get_module_platform(module_name: str) -> str | None: # Or `homeassistant.components..` return None - platform = module_match.groups()[0] + platform = module_match.group(1) return platform.lstrip(".") if platform else "__init__" diff --git a/pyproject.toml b/pyproject.toml index 995308bbf0d..4ed99327499 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0.dev0" +version = "2025.9.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." @@ -27,8 +27,8 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.3.1", - "aiohttp==3.12.13", + "aiohasupervisor==0.3.2b0", + "aiohttp==3.12.15", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.103.0", + "hass-nabucasa==1.0.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", @@ -58,10 +58,10 @@ dependencies = [ "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==45.0.3", - "Pillow==11.2.1", + "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.10.18", + "orjson==3.11.2", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", @@ -74,9 +74,9 @@ dependencies = [ "typing-extensions>=4.14.0,<5.0", "ulid-transform==1.4.0", "urllib3>=2.0", - "uv==0.7.1", + "uv==0.8.9", "voluptuous==0.15.2", - "voluptuous-serialize==2.6.0", + "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.1.0", "yarl==1.20.1", "webrtc-models==0.3.0", @@ -118,8 +118,10 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_async_load_fixtures", "hass_decorator", "hass_enforce_class_module", + "hass_enforce_greek_micro_char", "hass_enforce_sorted_platforms", "hass_enforce_super_call", "hass_enforce_type_hints", @@ -450,7 +452,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ "error::sqlalchemy.exc.SAWarning", - "error:usefixtures\\(\\) in .* without arguments has no effect:UserWarning", # pytest + "error:usefixtures\\(\\) in .* without arguments has no effect:UserWarning", # pytest # -- HomeAssistant - aiohttp # Overwrite web.Application to pass a custom default argument to _make_request @@ -486,19 +488,10 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", # -- fixed, waiting for release / update - # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:.*invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", - # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 - "ignore:pkg_resources is deprecated as an API:UserWarning:datadog.util.compat", # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 - "ignore::DeprecationWarning:holidays", # https://github.com/ReactiveX/RxPY/pull/716 - >4.0.4 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:reactivex.internal.constants", - # https://github.com/postlund/pyatv/issues/2645 - >0.16.0 - # https://github.com/postlund/pyatv/pull/2664 - "ignore:Protobuf gencode .* exactly one major version older than the runtime version 6.* at pyatv:UserWarning:google.protobuf.runtime_version", # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", @@ -525,6 +518,9 @@ filterwarnings = [ "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/motionblindsble/ - v0.1.3 - 2024-11-12 + # https://github.com/LennP/motionblindsble/blob/0.1.3/motionblindsble/device.py#L390 + "ignore:Passing additional arguments for BLEDevice is deprecated and has no effect:DeprecationWarning:motionblindsble.device", # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", @@ -541,8 +537,6 @@ filterwarnings = [ "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # New in aiohttp - v3.9.0 - "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # - SyntaxWarnings # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 "ignore:.*invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", @@ -567,8 +561,6 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:UserWarning:pysiaalarm.data.data", # https://pypi.org/project/pybotvac/ - v0.0.28 - 2025-06-11 "ignore:pkg_resources is deprecated as an API:UserWarning:pybotvac.version", - # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 - "ignore:pkg_resources is deprecated as an API:UserWarning:pymystrom", # - SyntaxWarning - is with literal # https://github.com/majuss/lupupy/pull/15 - >0.3.2 # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 @@ -590,11 +582,7 @@ filterwarnings = [ # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/bluecurrent/HomeAssistantAPI/pull/19 - >=1.2.4 - "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", - # https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0 + # https://github.com/graphql-python/gql/pull/543 - >=4.0.0b0 "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", # -- unmaintained projects, last release about 2+ years @@ -652,7 +640,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.11.0" +required-version = ">=0.12.1" [tool.ruff.lint] select = [ @@ -912,4 +900,5 @@ split-on-trailing-comma = false max-complexity = 25 [tool.ruff.lint.pydocstyle] +convention = "google" property-decorators = ["propcache.api.cached_property"] diff --git a/requirements.txt b/requirements.txt index 687e5584355..e94f0c3caea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,8 @@ # Home Assistant Core aiodns==3.5.0 -aiohasupervisor==0.3.1 -aiohttp==3.12.13 +aiohasupervisor==0.3.2b0 +aiohttp==3.12.15 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.103.0 +hass-nabucasa==1.0.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 @@ -30,10 +30,10 @@ Jinja2==3.1.6 lru-dict==1.3.0 PyJWT==2.10.1 cryptography==45.0.3 -Pillow==11.2.1 +Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.10.18 +orjson==3.11.2 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 @@ -46,9 +46,9 @@ standard-telnetlib==3.13.0 typing-extensions>=4.14.0,<5.0 ulid-transform==1.4.0 urllib3>=2.0 -uv==0.7.1 +uv==0.8.9 voluptuous==0.15.2 -voluptuous-serialize==2.6.0 +voluptuous-serialize==2.7.0 voluptuous-openapi==0.1.0 yarl==1.20.1 webrtc-models==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 80f543f790f..b003ebc4219 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==2.0.1 +Mastodon.py==2.1.1 # homeassistant.components.playstation_network PSNAWP==3.0.0 @@ -36,7 +36,7 @@ PSNAWP==3.0.0 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.2.1 +Pillow==11.3.0 # homeassistant.components.plex PlexAPI==4.15.16 @@ -70,7 +70,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.66.0 +PySwitchbot==0.68.4 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -179,13 +179,13 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.12 +aioairzone-cloud==0.7.1 # homeassistant.components.airzone aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.14 +aioamazondevices==4.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.6.0 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -220,10 +220,10 @@ aiobotocore==2.21.1 aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.2.0 +aiodhcpwatcher==1.2.1 # homeassistant.components.dhcp -aiodiscover==2.7.0 +aiodiscover==2.7.1 # homeassistant.components.dnsip aiodns==3.5.0 @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==33.1.1 +aioesphomeapi==39.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -265,7 +265,7 @@ aioguardian==2022.07.0 aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.1 +aiohasupervisor==0.3.2b0 # homeassistant.components.home_connect aiohomeconnect==0.18.1 @@ -283,7 +283,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.1 +aioimmich==0.11.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -301,7 +301,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.5 +aiolifx==1.2.1 # homeassistant.components.lookin aiolookin==1.0.0 @@ -310,7 +310,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -325,11 +325,14 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.3 +aiontfy==0.5.4 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.3.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -372,7 +375,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.6.1 +aiorussound==4.8.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -381,7 +384,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.6.0 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -414,7 +417,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==83 +aiounifi==86 # homeassistant.components.usb aiousbwatcher==1.1.1 @@ -435,7 +438,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.3 +aiowebostv==0.7.5 # homeassistant.components.withings aiowithings==3.1.6 @@ -449,6 +452,9 @@ airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airos +airos==0.3.0 + # homeassistant.components.airthings_ble airthings-ble==0.9.2 @@ -471,13 +477,13 @@ altruistclient==0.1.1 amberelectric==2.0.12 # homeassistant.components.amcrest -amcrest==1.9.8 +amcrest==1.9.9 # homeassistant.components.androidtv androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.2 +androidtvremote2==0.2.3 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -489,7 +495,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.52.0 +anthropic==0.62.0 # homeassistant.components.mcp_server anyio==4.9.0 @@ -513,7 +519,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 @@ -521,13 +527,16 @@ arris-tg2492lg==2.2.0 # homeassistant.components.ampio asmog==0.0.6 +# homeassistant.components.asuswrt +asusrouter==1.19.0 + # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -539,7 +548,7 @@ asyncinotify==4.2.0 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.5.2 +asyncsleepiq==1.6.0 # homeassistant.components.aten_pe # atenpdu==0.3.2 @@ -567,7 +576,7 @@ av==13.1.0 # avion==0.10 # homeassistant.components.axis -axis==64 +axis==65 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -616,13 +625,13 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.16.0 +bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.9.0 +bleak-retry-connector==4.0.2 # homeassistant.components.bluetooth -bleak==0.22.3 +bleak==1.0.1 # homeassistant.components.blebox blebox-uniapi==2.5.0 @@ -634,7 +643,7 @@ blinkpy==0.23.0 blockchain==1.4.4 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.3.1 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 @@ -643,7 +652,7 @@ bluemaestro-ble==0.4.1 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.4 +bluetooth-adapters==2.0.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.5.2 @@ -652,7 +661,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 @@ -661,7 +670,7 @@ bond-async==0.2.1 bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc -boschshcpy==0.2.91 +boschshcpy==0.2.107 # homeassistant.components.amazon_polly # homeassistant.components.route53 @@ -677,7 +686,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.1 +brother==5.0.1 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -718,9 +727,6 @@ clx-sdk-xms==1.0.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 -# homeassistant.components.coinbase -coinbase==2.1.0 - # homeassistant.scripts.check_config colorlog==6.9.0 @@ -737,7 +743,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.12.2 +cookidoo-api==0.14.0 # homeassistant.components.backup # homeassistant.components.utility_meter @@ -753,13 +759,13 @@ crownstone-sse==2.0.5 crownstone-uart==2.1.0 # homeassistant.components.datadog -datadog==0.15.0 +datadog==0.52.0 # homeassistant.components.metoffice datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.3 # homeassistant.components.debugpy debugpy==1.8.14 @@ -771,7 +777,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -785,7 +791,7 @@ deluge-client==1.10.2 demetriek==1.3.0 # homeassistant.components.denonavr -denonavr==1.1.1 +denonavr==1.1.2 # homeassistant.components.devialet devialet==1.5.7 @@ -820,9 +826,6 @@ dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 -# homeassistant.components.dweet -dweepy==0.3.0 - # homeassistant.components.dynalite dynalite-devices==0.1.47 @@ -842,13 +845,13 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.2.0 +eheimdigital==1.3.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 @@ -992,10 +995,10 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.12 +gassist-text==0.0.14 # homeassistant.components.google -gcal-sync==7.1.0 +gcal-sync==8.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -1023,7 +1026,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.0.0 +gios==6.1.2 # homeassistant.components.gitter gitterpy==0.1.7 @@ -1038,7 +1041,7 @@ go2rtc-client==0.2.1 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.6 +goodwe==0.4.8 # homeassistant.components.google_mail # homeassistant.components.google_tasks @@ -1054,7 +1057,7 @@ google-cloud-speech==2.31.1 google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.7.0 +google-genai==1.29.0 # homeassistant.components.google_travel_time google-maps-routing==0.6.15 @@ -1097,7 +1100,7 @@ greenwavereality==0.5.1 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.6.0 +growattServer==1.7.1 # homeassistant.components.google_sheets gspread==5.5.0 @@ -1124,20 +1127,20 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.2 # homeassistant.components.bluetooth -habluetooth==3.49.0 +habluetooth==5.0.1 # homeassistant.components.cloud -hass-nabucasa==0.103.0 +hass-nabucasa==1.0.0 # homeassistant.components.splunk hass-splunk==0.1.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==2.2.3 +hassil==3.1.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 @@ -1164,20 +1167,20 @@ hko==0.3.2 hlk-sw16==0.0.9 # homeassistant.components.pi_hole -hole==0.8.0 +hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.75 +holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250811.0 # homeassistant.components.conversation -home-assistant-intents==2025.6.23 +home-assistant-intents==2025.7.30 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.2.0 # homeassistant.components.horizon horimote==0.4.1 @@ -1189,7 +1192,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 @@ -1210,10 +1213,10 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.4 +ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 @@ -1234,10 +1237,10 @@ igloohome-api==0.1.1 ihcsdk==2.8.5 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.1.0 +imgw_pib==1.5.3 # homeassistant.components.incomfort incomfort-client==0.6.9 @@ -1249,7 +1252,7 @@ influxdb-client==1.48.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.2 +inkbird-ble==1.1.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1273,13 +1276,13 @@ isal==1.7.1 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.2 +israel-rail-api==0.1.3 # homeassistant.components.abode jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest @@ -1304,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.8.9.63154 # homeassistant.components.konnected konnected==1.2.0 @@ -1322,7 +1325,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.5 +lcn-frontend==0.2.6 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1337,10 +1340,10 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.6.1 # homeassistant.components.foscam -libpyfoscamcgi==0.0.6 +libpyfoscamcgi==0.0.7 # homeassistant.components.vivotek libpyvivotek==0.4.0 @@ -1360,9 +1363,6 @@ lightwave==0.24 # homeassistant.components.limitlessled limitlessled==1.1.3 -# homeassistant.components.linear_garage_door -linear-garage-door==0.2.9 - # homeassistant.components.linode linode-api==4.1.9b1 @@ -1388,7 +1388,7 @@ lupupy==0.3.2 lw12==0.9.2 # homeassistant.components.scrape -lxml==5.3.0 +lxml==6.0.0 # homeassistant.components.matrix matrix-nio==0.25.2 @@ -1449,13 +1449,13 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo -monzopy==1.4.2 +monzopy==1.5.1 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.28 +motionblinds==0.6.30 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1470,7 +1470,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.4 # homeassistant.components.tts mutagen==1.47.0 @@ -1500,7 +1500,7 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1515,7 +1515,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.0.0 +nextdns==4.1.0 # homeassistant.components.niko_home_control nhc==0.4.12 @@ -1555,10 +1555,10 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.3.0 +numpy==2.3.2 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1570,7 +1570,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.1.1 +odp-amsterdam==6.1.2 # homeassistant.components.oem oemthermostat==1.1.1 @@ -1591,7 +1591,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.1 +onvif-zeep-async==4.0.3 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1599,8 +1599,9 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.76.2 +openai==1.99.5 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1624,7 +1625,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.2 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1692,7 +1693,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.4 +plugwise==1.7.8 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1757,13 +1758,13 @@ py-cpuinfo==9.0.0 py-dactyl==2.0.4 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.5 +py-dormakaba-dkey==1.0.6 # homeassistant.components.improv_ble py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.32 +py-madvr2==1.6.40 # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1814,10 +1815,10 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.w800rf32 pyW800rf32==0.4 @@ -1832,7 +1833,7 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.2.1 +pyairnow==1.3.1 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro @@ -1841,17 +1842,14 @@ pyairvisual==2023.08.1 # homeassistant.components.aprilaire pyaprilaire==0.9.1 -# homeassistant.components.asuswrt -pyasuswrt==0.1.21 - # homeassistant.components.atag pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==9.2.1 +pyatmo==9.2.3 # homeassistant.components.apple_tv -pyatv==0.16.0 +pyatv==0.16.1 # homeassistant.components.aussie_broadband pyaussiebb==0.1.5 @@ -1866,7 +1864,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.1 +pyblu==2.0.4 # homeassistant.components.neato pybotvac==0.0.28 @@ -1908,7 +1906,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.15.0 +pydaikin==2.16.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -1932,7 +1930,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.6.0 +pydrawise==2025.7.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 @@ -1958,14 +1956,12 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms -pyemoncms==0.1.1 +# homeassistant.components.emoncms_history +pyemoncms==0.1.2 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.3.0 # homeassistant.components.envisalink pyenvisalink==4.7 @@ -2070,7 +2066,7 @@ pyisy==3.4.1 pyitachip2ir==0.0.7 # homeassistant.components.ituran -pyituran==0.1.4 +pyituran==0.1.5 # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 @@ -2103,7 +2099,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.9 +pylamarzocco==2.0.11 # homeassistant.components.lastfm pylast==5.1.0 @@ -2121,7 +2117,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.0 +pylitterbot==2024.2.3 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 @@ -2145,7 +2141,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.2 +pymiele==0.5.4 # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -2154,7 +2150,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.9.2 +pymodbus==3.11.1 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2163,10 +2159,10 @@ pymonoprice==0.4 pymsteams==0.1.12 # homeassistant.components.mysensors -pymysensors==0.25.0 +pymysensors==0.26.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -2199,7 +2195,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.12 +pyoctoprintapi==0.1.14 # homeassistant.components.ombi pyombi==0.1.10 @@ -2217,7 +2213,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.5 +pyosoenergyapi==1.2.4 # homeassistant.components.opentherm_gw pyotgw==2.2.2 @@ -2237,13 +2233,13 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.elv pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.9 +pypck==0.8.10 # homeassistant.components.pglab pypglab==0.0.5 @@ -2312,7 +2308,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 @@ -2348,10 +2344,10 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.2.5 +pysmartthings==3.2.9 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -2363,10 +2359,10 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.6 +pysmlight==0.2.7 # homeassistant.components.snmp -pysnmp==6.2.6 +pysnmp==7.1.21 # homeassistant.components.snooz pysnooz==0.8.6 @@ -2384,10 +2380,10 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.1 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.1.0 +pystiebeleltron==0.2.3 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2465,7 +2461,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==7.0.0 +python-matter-server==8.1.0 # homeassistant.components.melcloud python-melcloud==0.1.0 @@ -2477,7 +2473,10 @@ python-miio==0.5.12 python-mpd2==3.1.1 # homeassistant.components.mystrom -python-mystrom==2.2.0 +python-mystrom==2.4.0 + +# homeassistant.components.open_router +python-open-router==0.3.1 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 @@ -2505,10 +2504,10 @@ python-ripple-api==0.0.3 python-roborock==2.18.2 # homeassistant.components.smarttub -python-smarttub==0.0.39 +python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.6.6 +python-snoo==0.8.3 # homeassistant.components.songpal python-songpal==0.16.2 @@ -2528,6 +2527,9 @@ python-vlc==3.0.18122 # homeassistant.components.egardia pythonegardia==1.0.52 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.1 + # homeassistant.components.tile pytile==2024.12.0 @@ -2538,7 +2540,7 @@ pytomorrowio==0.3.6 pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server @@ -2599,7 +2601,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 @@ -2620,7 +2622,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.3.0 +qbusmqttapi==1.4.2 # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2653,13 +2655,13 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.3.1 +renault-api==0.4.0 # homeassistant.components.renson renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.6 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2701,7 +2703,7 @@ rpi-bad-power==0.1.0 russound==0.2.0 # homeassistant.components.ruuvitag_ble -ruuvitag-ble==0.1.2 +ruuvitag-ble==0.2.1 # homeassistant.components.yamaha rxv==0.7.0 @@ -2719,7 +2721,7 @@ sanix==1.0.6 satel-integra==0.3.7 # homeassistant.components.screenlogic -screenlogicpy==0.10.0 +screenlogicpy==0.10.2 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2756,10 +2758,10 @@ sensoterra==2.0.1 sentry-sdk==1.45.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.11 +sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.1.0 +sharkiq==1.1.1 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 @@ -2789,7 +2791,7 @@ skyboxremote==0.0.6 slack_sdk==3.33.4 # homeassistant.components.xmpp -slixmpp==1.8.5 +slixmpp==1.10.0 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 @@ -2798,13 +2800,13 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.9 +soco==0.30.11 # homeassistant.components.solaredge_local solaredge-local==0.2.3 # homeassistant.components.solarlog -solarlog_cli==0.4.0 +solarlog_cli==0.5.0 # homeassistant.components.solax solax==3.2.3 @@ -2822,7 +2824,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.11 +spotifyaio==1.0.0 # homeassistant.components.sql sqlparse==0.5.0 @@ -2866,16 +2868,13 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.5.0 +switchbot-api==2.7.0 # homeassistant.components.synology_srm synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==4.1.5 - -# homeassistant.components.system_bridge -systembridgemodels==4.2.4 +systembridgeconnector==4.1.10 # homeassistant.components.tailscale tailscale==0.6.2 @@ -2907,7 +2906,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2928,7 +2927,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.thingspeak thingspeak==1.0.0 @@ -2940,7 +2939,7 @@ thinqconnect==1.0.7 tikteck==0.4 # homeassistant.components.tilt_ble -tilt-ble==0.2.3 +tilt-ble==0.3.1 # homeassistant.components.tilt_pi tilt-pi==0.2.1 @@ -2951,6 +2950,9 @@ tmb==0.0.4 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.7.0 + # homeassistant.components.tolo tololib==1.2.2 @@ -2958,7 +2960,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.1.4 +total-connect-client==2025.5 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -2997,7 +2999,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.21.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -3044,17 +3046,20 @@ vehicle==2.2.2 velbus-aio==2025.5.0 # homeassistant.components.venstar -venstarcolortouch==0.19 +venstarcolortouch==0.21 # homeassistant.components.vilfo vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.4 # homeassistant.components.volkszaehler volkszaehler==0.4.0 +# homeassistant.components.volvo +volvocarsapi==0.4.1 + # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -3065,7 +3070,7 @@ vsure==2.6.7 vtjp==0.2.1 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 @@ -3087,7 +3092,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.3.1 +weatherflow4py==1.4.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 @@ -3102,7 +3107,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.6.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.20.0 +whirlpool-sixth-sense==0.21.1 # homeassistant.components.whois whois==0.9.27 @@ -3123,13 +3128,13 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.39.0 +xiaomi-ble==1.2.0 # homeassistant.components.knx xknx==3.8.0 @@ -3153,11 +3158,11 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.6.0 +yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.10.0 +yalexs==8.11.1 # homeassistant.components.yeelight yeelight==0.7.16 @@ -3166,16 +3171,16 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.5.5 +yolink-api==0.5.8 # homeassistant.components.youless youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.06.09 +yt-dlp[default]==2025.08.11 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3184,7 +3189,7 @@ zabbix-utils==2.0.2 zamg==0.3.6 # homeassistant.components.zimi -zcc-helper==3.5.2 +zcc-helper==3.6 # homeassistant.components.zeroconf zeroconf==0.147.0 @@ -3193,7 +3198,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.60 +zha==0.0.68 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 @@ -3205,7 +3210,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.67.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index 29d2618c69d..9df62168b19 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,21 +7,21 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.10 -coverage==7.8.2 +astroid==3.3.11 +coverage==7.10.0 freezegun==1.5.2 go2rtc-client==0.2.1 -license-expression==30.4.1 +license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.17.0a2 +mypy-dev==1.18.0a4 pre-commit==4.2.0 pydantic==2.11.7 -pylint==3.3.7 +pylint==3.3.8 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 -pytest-asyncio==1.0.0 +pytest-asyncio==1.1.0 pytest-aiohttp==1.1.0 -pytest-cov==6.1.1 +pytest-cov==6.2.1 pytest-freezer==0.4.9 pytest-github-actions-annotate-failures==0.3.0 pytest-socket==0.7.0 @@ -29,25 +29,25 @@ pytest-sugar==1.0.0 pytest-timeout==2.4.0 pytest-unordered==0.7.0 pytest-picked==0.5.1 -pytest-xdist==3.7.0 -pytest==8.4.0 +pytest-xdist==3.8.0 +pytest==8.4.1 requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20250606 +types-aiofiles==24.1.0.20250809 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250411 +types-croniter==6.0.0.20250809 types-caldav==1.3.0.20250516 types-chardet==0.1.5 types-decorator==5.2.0.20250324 -types-pexpect==4.9.0.20250516 -types-protobuf==6.30.2.20250516 -types-psutil==7.0.0.20250601 -types-pyserial==3.5.0.20250326 -types-python-dateutil==2.9.0.20250516 +types-pexpect==4.9.0.20250809 +types-protobuf==6.30.2.20250809 +types-psutil==7.0.0.20250801 +types-pyserial==3.5.0.20250809 +types-python-dateutil==2.9.0.20250809 types-python-slugify==8.0.2.20240310 -types-pytz==2025.2.0.20250516 -types-PyYAML==6.0.12.20250516 -types-requests==2.32.4.20250611 +types-pytz==2025.2.0.20250809 +types-PyYAML==6.0.12.20250809 +types-requests==2.32.4.20250809 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1a546dfe2f..07468c5f878 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==2.0.1 +Mastodon.py==2.1.1 # homeassistant.components.playstation_network PSNAWP==3.0.0 @@ -36,7 +36,7 @@ PSNAWP==3.0.0 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.2.1 +Pillow==11.3.0 # homeassistant.components.plex PlexAPI==4.15.16 @@ -67,7 +67,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.66.0 +PySwitchbot==0.68.4 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -167,13 +167,13 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.12 +aioairzone-cloud==0.7.1 # homeassistant.components.airzone aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.14 +aioamazondevices==4.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.6.0 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -208,10 +208,10 @@ aiobotocore==2.21.1 aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.2.0 +aiodhcpwatcher==1.2.1 # homeassistant.components.dhcp -aiodiscover==2.7.0 +aiodiscover==2.7.1 # homeassistant.components.dnsip aiodns==3.5.0 @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==33.1.1 +aioesphomeapi==39.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.1 +aiohasupervisor==0.3.2b0 # homeassistant.components.home_connect aiohomeconnect==0.18.1 @@ -268,7 +268,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.1 +aioimmich==0.11.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -283,7 +283,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.5 +aiolifx==1.2.1 # homeassistant.components.lookin aiolookin==1.0.0 @@ -292,7 +292,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -307,11 +307,14 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.3 +aiontfy==0.5.4 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.3.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -354,7 +357,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.6.1 +aiorussound==4.8.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -363,7 +366,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.6.0 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -396,7 +399,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==83 +aiounifi==86 # homeassistant.components.usb aiousbwatcher==1.1.1 @@ -417,7 +420,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.3 +aiowebostv==0.7.5 # homeassistant.components.withings aiowithings==3.1.6 @@ -431,6 +434,9 @@ airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airos +airos==0.3.0 + # homeassistant.components.airthings_ble airthings-ble==0.9.2 @@ -453,7 +459,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.2 +androidtvremote2==0.2.3 # homeassistant.components.anova anova-wifi==0.17.0 @@ -462,7 +468,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.52.0 +anthropic==0.62.0 # homeassistant.components.mcp_server anyio==4.9.0 @@ -483,7 +489,10 @@ apsystems-ez1==2.7.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 + +# homeassistant.components.asuswrt +asusrouter==1.19.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -491,13 +500,13 @@ arcam-fmj==1.8.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 # homeassistant.components.sleepiq -asyncsleepiq==1.5.2 +asyncsleepiq==1.6.0 # homeassistant.components.aurora auroranoaa==0.0.5 @@ -516,7 +525,7 @@ automower-ble==0.2.1 av==13.1.0 # homeassistant.components.axis -axis==64 +axis==65 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -550,13 +559,13 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.16.0 +bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.9.0 +bleak-retry-connector==4.0.2 # homeassistant.components.bluetooth -bleak==0.22.3 +bleak==1.0.1 # homeassistant.components.blebox blebox-uniapi==2.5.0 @@ -565,7 +574,7 @@ blebox-uniapi==2.5.0 blinkpy==0.23.0 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.3.1 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 @@ -574,7 +583,7 @@ bluemaestro-ble==0.4.1 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.4 +bluetooth-adapters==2.0.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.5.2 @@ -583,7 +592,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 @@ -592,7 +601,7 @@ bond-async==0.2.1 bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc -boschshcpy==0.2.91 +boschshcpy==0.2.107 # homeassistant.components.aws botocore==1.37.1 @@ -604,7 +613,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.1 +brother==5.0.1 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -627,9 +636,6 @@ caldav==1.6.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 -# homeassistant.components.coinbase -coinbase==2.1.0 - # homeassistant.scripts.check_config colorlog==6.9.0 @@ -640,7 +646,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.12.2 +cookidoo-api==0.14.0 # homeassistant.components.backup # homeassistant.components.utility_meter @@ -656,13 +662,13 @@ crownstone-sse==2.0.5 crownstone-uart==2.1.0 # homeassistant.components.datadog -datadog==0.15.0 +datadog==0.52.0 # homeassistant.components.metoffice datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.3 # homeassistant.components.debugpy debugpy==1.8.14 @@ -671,7 +677,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -685,7 +691,7 @@ deluge-client==1.10.2 demetriek==1.3.0 # homeassistant.components.denonavr -denonavr==1.1.1 +denonavr==1.1.2 # homeassistant.components.devialet devialet==1.5.7 @@ -730,13 +736,13 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.2.0 +eheimdigital==1.3.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 @@ -859,10 +865,10 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.12 +gassist-text==0.0.14 # homeassistant.components.google -gcal-sync==7.1.0 +gcal-sync==8.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -890,7 +896,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.0.0 +gios==6.1.2 # homeassistant.components.glances glances-api==0.8.0 @@ -902,7 +908,7 @@ go2rtc-client==0.2.1 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.6 +goodwe==0.4.8 # homeassistant.components.google_mail # homeassistant.components.google_tasks @@ -918,7 +924,7 @@ google-cloud-speech==2.31.1 google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.7.0 +google-genai==1.29.0 # homeassistant.components.google_travel_time google-maps-routing==0.6.15 @@ -955,7 +961,7 @@ greeneye_monitor==3.0.3 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.6.0 +growattServer==1.7.1 # homeassistant.components.google_sheets gspread==5.5.0 @@ -982,17 +988,17 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.2 # homeassistant.components.bluetooth -habluetooth==3.49.0 +habluetooth==5.0.1 # homeassistant.components.cloud -hass-nabucasa==0.103.0 +hass-nabucasa==1.0.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==2.2.3 +hassil==3.1.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 @@ -1010,20 +1016,20 @@ hko==0.3.2 hlk-sw16==0.0.9 # homeassistant.components.pi_hole -hole==0.8.0 +hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.75 +holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250811.0 # homeassistant.components.conversation -home-assistant-intents==2025.6.23 +home-assistant-intents==2025.7.30 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.2.0 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1032,7 +1038,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 @@ -1047,10 +1053,10 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.4 +ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 @@ -1065,10 +1071,10 @@ ifaddr==0.2.0 igloohome-api==0.1.1 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.1.0 +imgw_pib==1.5.3 # homeassistant.components.incomfort incomfort-client==0.6.9 @@ -1080,7 +1086,7 @@ influxdb-client==1.48.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.2 +inkbird-ble==1.1.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1101,13 +1107,13 @@ isal==1.7.1 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.2 +israel-rail-api==0.1.3 # homeassistant.components.abode jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest @@ -1123,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.8.9.63154 # homeassistant.components.konnected konnected==1.2.0 @@ -1138,7 +1144,7 @@ lacrosse-view==1.1.1 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.5 +lcn-frontend==0.2.6 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1153,10 +1159,10 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.6.1 # homeassistant.components.foscam -libpyfoscamcgi==0.0.6 +libpyfoscamcgi==0.0.7 # homeassistant.components.mikrotik librouteros==3.2.0 @@ -1164,9 +1170,6 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 -# homeassistant.components.linear_garage_door -linear-garage-door==0.2.9 - # homeassistant.components.livisi livisi==0.0.25 @@ -1183,7 +1186,7 @@ luftdaten==0.7.4 lupupy==0.3.2 # homeassistant.components.scrape -lxml==5.3.0 +lxml==6.0.0 # homeassistant.components.matrix matrix-nio==0.25.2 @@ -1238,13 +1241,13 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo -monzopy==1.4.2 +monzopy==1.5.1 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.28 +motionblinds==0.6.30 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1259,7 +1262,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.4 # homeassistant.components.tts mutagen==1.47.0 @@ -1283,7 +1286,7 @@ nessclient==1.2.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.nexia nexia==2.10.0 @@ -1295,7 +1298,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.0.0 +nextdns==4.1.0 # homeassistant.components.niko_home_control nhc==0.4.12 @@ -1326,10 +1329,10 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.3.0 +numpy==2.3.2 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.google oauth2client==4.1.3 @@ -1338,7 +1341,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.1.1 +odp-amsterdam==6.1.2 # homeassistant.components.ohme ohme==1.5.1 @@ -1356,7 +1359,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.1 +onvif-zeep-async==4.0.3 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1364,8 +1367,9 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.76.2 +openai==1.99.5 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1377,7 +1381,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.2 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1427,7 +1431,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.4 +plugwise==1.7.8 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1480,13 +1484,13 @@ py-cpuinfo==9.0.0 py-dactyl==2.0.4 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.5 +py-dormakaba-dkey==1.0.6 # homeassistant.components.improv_ble py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.32 +py-madvr2==1.6.40 # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1522,10 +1526,10 @@ pyHomee==1.2.10 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 @@ -1534,7 +1538,7 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.2.1 +pyairnow==1.3.1 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro @@ -1543,17 +1547,14 @@ pyairvisual==2023.08.1 # homeassistant.components.aprilaire pyaprilaire==0.9.1 -# homeassistant.components.asuswrt -pyasuswrt==0.1.21 - # homeassistant.components.atag pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==9.2.1 +pyatmo==9.2.3 # homeassistant.components.apple_tv -pyatv==0.16.0 +pyatv==0.16.1 # homeassistant.components.aussie_broadband pyaussiebb==0.1.5 @@ -1565,7 +1566,7 @@ pybalboa==1.1.3 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.1 +pyblu==2.0.4 # homeassistant.components.neato pybotvac==0.0.28 @@ -1595,7 +1596,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.15.0 +pydaikin==2.16.0 # homeassistant.components.deako pydeako==0.6.0 @@ -1610,7 +1611,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.6.0 +pydrawise==2025.7.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 @@ -1630,14 +1631,12 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms -pyemoncms==0.1.1 +# homeassistant.components.emoncms_history +pyemoncms==0.1.2 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.3.0 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1718,7 +1717,7 @@ pyiss==1.0.1 pyisy==3.4.1 # homeassistant.components.ituran -pyituran==0.1.4 +pyituran==0.1.5 # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 @@ -1745,7 +1744,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.9 +pylamarzocco==2.0.11 # homeassistant.components.lastfm pylast==5.1.0 @@ -1763,7 +1762,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.0 +pylitterbot==2024.2.3 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 @@ -1784,22 +1783,22 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.2 +pymiele==0.5.4 # homeassistant.components.mochad pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.9.2 +pymodbus==3.11.1 # homeassistant.components.monoprice pymonoprice==0.4 # homeassistant.components.mysensors -pymysensors==0.25.0 +pymysensors==0.26.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -1829,7 +1828,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.12 +pyoctoprintapi==0.1.14 # homeassistant.components.openuv pyopenuv==2023.02.0 @@ -1841,7 +1840,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.5 +pyosoenergyapi==1.2.4 # homeassistant.components.opentherm_gw pyotgw==2.2.2 @@ -1861,10 +1860,10 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.lcn -pypck==0.8.9 +pypck==0.8.10 # homeassistant.components.pglab pypglab==0.0.5 @@ -1921,7 +1920,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 @@ -1948,10 +1947,10 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.2.5 +pysmartthings==3.2.9 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -1963,10 +1962,10 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.6 +pysmlight==0.2.7 # homeassistant.components.snmp -pysnmp==6.2.6 +pysnmp==7.1.21 # homeassistant.components.snooz pysnooz==0.8.6 @@ -1984,10 +1983,10 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.1 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.1.0 +pystiebeleltron==0.2.3 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2035,7 +2034,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==7.0.0 +python-matter-server==8.1.0 # homeassistant.components.melcloud python-melcloud==0.1.0 @@ -2047,7 +2046,10 @@ python-miio==0.5.12 python-mpd2==3.1.1 # homeassistant.components.mystrom -python-mystrom==2.2.0 +python-mystrom==2.4.0 + +# homeassistant.components.open_router +python-open-router==0.3.1 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 @@ -2072,10 +2074,10 @@ python-rabbitair==0.0.8 python-roborock==2.18.2 # homeassistant.components.smarttub -python-smarttub==0.0.39 +python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.6.6 +python-snoo==0.8.3 # homeassistant.components.songpal python-songpal==0.16.2 @@ -2089,6 +2091,9 @@ python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.1 + # homeassistant.components.tile pytile==2024.12.0 @@ -2096,7 +2101,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server @@ -2154,7 +2159,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 @@ -2169,7 +2174,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.3.0 +qbusmqttapi==1.4.2 # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2196,13 +2201,13 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.3.1 +renault-api==0.4.0 # homeassistant.components.renson renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.6 # homeassistant.components.rflink rflink==0.0.67 @@ -2229,7 +2234,7 @@ rova==0.4.1 rpi-bad-power==0.1.0 # homeassistant.components.ruuvitag_ble -ruuvitag-ble==0.1.2 +ruuvitag-ble==0.2.1 # homeassistant.components.yamaha rxv==0.7.0 @@ -2244,7 +2249,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.screenlogic -screenlogicpy==0.10.0 +screenlogicpy==0.10.2 # homeassistant.components.backup securetar==2025.2.1 @@ -2275,10 +2280,10 @@ sensoterra==2.0.1 sentry-sdk==1.45.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.11 +sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.1.0 +sharkiq==1.1.1 # homeassistant.components.simplefin simplefin4py==0.0.18 @@ -2305,10 +2310,10 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.9 +soco==0.30.11 # homeassistant.components.solarlog -solarlog_cli==0.4.0 +solarlog_cli==0.5.0 # homeassistant.components.solax solax==3.2.3 @@ -2326,7 +2331,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.11 +spotifyaio==1.0.0 # homeassistant.components.sql sqlparse==0.5.0 @@ -2364,13 +2369,10 @@ subarulink==0.7.13 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.5.0 +switchbot-api==2.7.0 # homeassistant.components.system_bridge -systembridgeconnector==4.1.5 - -# homeassistant.components.system_bridge -systembridgemodels==4.2.4 +systembridgeconnector==4.1.10 # homeassistant.components.tailscale tailscale==0.6.2 @@ -2390,7 +2392,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2411,13 +2413,13 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.lg_thinq thinqconnect==1.0.7 # homeassistant.components.tilt_ble -tilt-ble==0.2.3 +tilt-ble==0.3.1 # homeassistant.components.tilt_pi tilt-pi==0.2.1 @@ -2425,6 +2427,9 @@ tilt-pi==0.2.1 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.7.0 + # homeassistant.components.tolo tololib==1.2.2 @@ -2432,7 +2437,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.1.4 +total-connect-client==2025.5 # homeassistant.components.tplink_omada tplink-omada-client==1.4.4 @@ -2468,7 +2473,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.21.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2509,13 +2514,16 @@ vehicle==2.2.2 velbus-aio==2025.5.0 # homeassistant.components.venstar -venstarcolortouch==0.19 +venstarcolortouch==0.21 # homeassistant.components.vilfo vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.4 + +# homeassistant.components.volvo +volvocarsapi==0.4.1 # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2524,7 +2532,7 @@ volvooncall==0.10.3 vsure==2.6.7 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 @@ -2543,7 +2551,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.3.1 +weatherflow4py==1.4.1 # homeassistant.components.nasweb webio-api==0.1.11 @@ -2555,7 +2563,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.6.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.20.0 +whirlpool-sixth-sense==0.21.1 # homeassistant.components.whois whois==0.9.27 @@ -2573,13 +2581,13 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.39.0 +xiaomi-ble==1.2.0 # homeassistant.components.knx xknx==3.8.0 @@ -2600,32 +2608,32 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.6.0 +yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.10.0 +yalexs==8.11.1 # homeassistant.components.yeelight yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.5.5 +yolink-api==0.5.8 # homeassistant.components.youless youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.06.09 +yt-dlp[default]==2025.08.11 # homeassistant.components.zamg zamg==0.3.6 # homeassistant.components.zimi -zcc-helper==3.5.2 +zcc-helper==3.6 # homeassistant.components.zeroconf zeroconf==0.147.0 @@ -2634,10 +2642,10 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.60 +zha==0.0.68 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.67.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1abbf3977cf..b9c800be3ca 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.12.0 +ruff==0.12.1 yamllint==1.37.1 diff --git a/script/bootstrap b/script/bootstrap index e60342563ac..725cb856bbf 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -4,7 +4,7 @@ # Stop on errors set -e -cd "$(dirname "$0")/.." +cd "$(realpath "$(dirname "$0")/..")" echo "Installing development dependencies..." uv pip install wheel --constraint homeassistant/package_constraints.txt --upgrade diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 005d97175a7..9f65409b9be 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -144,7 +144,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.0 +numpy==2.3.2 pandas==2.3.0 # Constrain multidict to avoid typing issues @@ -170,18 +170,14 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.31.1 +protobuf==6.32.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python @@ -198,7 +194,7 @@ poetry==1000000000.0.0 # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.4.0 +charset-normalizer==3.4.3 # dacite: Ensure we have a version that is able to handle type unions for # NAM, Brother, and GIOS. @@ -239,10 +235,20 @@ aiofiles>=24.1.0 # https://github.com/aio-libs/multidict/issues/1131 multidict>=6.4.2 -# rpds-py > 0.25.0 requires cargo 1.84.0 -# Stable Alpine current only ships cargo 1.83.0 +# rpds-py frequently updates cargo causing build failures # No wheels upstream available for armhf & armv7 -rpds-py==0.24.0 +rpds-py==0.26.0 + +# Constraint num2words to 0.5.14 as 0.5.15 and 0.5.16 were removed from PyPI +num2words==0.5.14 + +# pymodbus does not follow SemVer, and it keeps getting +# downgraded or upgraded by custom components +# This ensures all use the same version +pymodbus==3.11.1 + +# Some packages don't support gql 4.0.0 yet +gql<4.0.0 """ GENERATED_MESSAGE = ( diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 277696c669b..dfa99c6bc75 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -12,6 +12,7 @@ from . import ( application_credentials, bluetooth, codeowners, + conditions, config_flow, config_schema, dependencies, @@ -28,6 +29,7 @@ from . import ( services, ssdp, translations, + triggers, usb, zeroconf, ) @@ -37,6 +39,7 @@ INTEGRATION_PLUGINS = [ application_credentials, bluetooth, codeowners, + conditions, config_schema, dependencies, dhcp, @@ -49,6 +52,7 @@ INTEGRATION_PLUGINS = [ services, ssdp, translations, + triggers, usb, zeroconf, config_flow, # This needs to run last, after translations are processed diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py new file mode 100644 index 00000000000..b9e9e7b82a4 --- /dev/null +++ b/script/hassfest/conditions.py @@ -0,0 +1,226 @@ +"""Validate conditions.""" + +from __future__ import annotations + +import contextlib +import json +import pathlib +import re +from typing import Any + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.const import CONF_SELECTOR +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import condition, config_validation as cv, selector +from homeassistant.util.yaml import load_yaml_dict + +from .model import Config, Integration + + +def exists(value: Any) -> Any: + """Check if value exists.""" + if value is None: + raise vol.Invalid("Value cannot be None") + return value + + +FIELD_SCHEMA = vol.Schema( + { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, + } +) + +CONDITION_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + } + ), + None, +) + +CONDITIONS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, condition.starts_with_dot)): object, + cv.underscore_slug: CONDITION_SCHEMA, + } +) + +NON_MIGRATED_INTEGRATIONS = { + "device_automation", + "sun", + "zone", +} + + +def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: + """Recursively go through a dir and it's children and find the regex.""" + pattern = re.compile(search_pattern) + + for fil in path.glob(glob_pattern): + if not fil.is_file(): + continue + + if pattern.search(fil.read_text()): + return True + + return False + + +def validate_conditions(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate conditions.""" + try: + data = load_yaml_dict(str(integration.path / "conditions.yaml")) + except FileNotFoundError: + # Find if integration uses conditions + has_conditions = grep_dir( + integration.path, + "**/condition.py", + r"async_get_conditions", + ) + + if has_conditions and integration.domain not in NON_MIGRATED_INTEGRATIONS: + integration.add_error( + "conditions", "Registers conditions but has no conditions.yaml" + ) + return + except HomeAssistantError: + integration.add_error("conditions", "Invalid conditions.yaml") + return + + try: + conditions = CONDITIONS_SCHEMA(data) + except vol.Invalid as err: + integration.add_error( + "conditions", f"Invalid conditions.yaml: {humanize_error(data, err)}" + ) + return + + icons_file = integration.path / "icons.json" + icons = {} + if icons_file.is_file(): + with contextlib.suppress(ValueError): + icons = json.loads(icons_file.read_text()) + condition_icons = icons.get("conditions", {}) + + # Try loading translation strings + if integration.core: + strings_file = integration.path / "strings.json" + else: + # For custom integrations, use the en.json file + strings_file = integration.path / "translations/en.json" + + strings = {} + if strings_file.is_file(): + with contextlib.suppress(ValueError): + strings = json.loads(strings_file.read_text()) + + error_msg_suffix = "in the translations file" + if not integration.core: + error_msg_suffix = f"and is not {error_msg_suffix}" + + # For each condition in the integration: + # 1. Check if the condition description is set, if not, + # check if it's in the strings file else add an error. + # 2. Check if the condition has an icon set in icons.json. + # raise an error if not., + for condition_name, condition_schema in conditions.items(): + if integration.core and condition_name not in condition_icons: + # This is enforced for Core integrations only + integration.add_error( + "conditions", + f"Condition {condition_name} has no icon in icons.json.", + ) + if condition_schema is None: + continue + if "name" not in condition_schema and integration.core: + try: + strings["conditions"][condition_name]["name"] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has no name {error_msg_suffix}", + ) + + if "description" not in condition_schema and integration.core: + try: + strings["conditions"][condition_name]["description"] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has no description {error_msg_suffix}", + ) + + # The same check is done for the description in each of the fields of the + # condition schema. + for field_name, field_schema in condition_schema.get("fields", {}).items(): + if "fields" in field_schema: + # This is a section + continue + if "name" not in field_schema and integration.core: + try: + strings["conditions"][condition_name]["fields"][field_name]["name"] + except KeyError: + integration.add_error( + "conditions", + ( + f"Condition {condition_name} has a field {field_name} with no " + f"name {error_msg_suffix}" + ), + ) + + if "description" not in field_schema and integration.core: + try: + strings["conditions"][condition_name]["fields"][field_name][ + "description" + ] + except KeyError: + integration.add_error( + "conditions", + ( + f"Condition {condition_name} has a field {field_name} with no " + f"description {error_msg_suffix}" + ), + ) + + if "selector" in field_schema: + with contextlib.suppress(KeyError): + translation_key = field_schema["selector"]["select"][ + "translation_key" + ] + try: + strings["selector"][translation_key] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file", + ) + + # The same check is done for the description in each of the sections of the + # condition schema. + for section_name, section_schema in condition_schema.get("fields", {}).items(): + if "fields" not in section_schema: + # This is not a section + continue + if "name" not in section_schema and integration.core: + try: + strings["conditions"][condition_name]["sections"][section_name][ + "name" + ] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has a section {section_name} with no name {error_msg_suffix}", + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle dependencies for integrations.""" + # check conditions.yaml is valid + for integration in integrations.values(): + validate_conditions(config, integration) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index ee932280201..447b3ec79b8 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -140,8 +140,6 @@ IGNORE_VIOLATIONS = { ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", - # Temporary needed for migration until 2024.10 - ("conversation", "assist_pipeline"), } diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index afd58539853..6dbb086f273 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG @@ -27,12 +27,12 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ stdlib-list==0.10.0 \ pipdeptree==2.26.1 \ tqdm==4.67.1 \ - ruff==0.12.0 \ + ruff==0.12.1 \ PyTurboJPEG==1.8.0 \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ - hassil==2.2.3 \ - home-assistant-intents==2025.6.23 \ + hassil==3.1.0 \ + home-assistant-intents==2025.7.30 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 563fe0edb93..6d2187e3fe6 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -120,6 +120,26 @@ CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA = cv.schema_with_slug_keys( ) +CONDITION_ICONS_SCHEMA = cv.schema_with_slug_keys( + vol.Schema( + { + vol.Optional("condition"): icon_value_validator, + } + ), + slug_validator=cv.underscore_slug, +) + + +TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys( + vol.Schema( + { + vol.Optional("trigger"): icon_value_validator, + } + ), + slug_validator=cv.underscore_slug, +) + + def icon_schema( core_integration: bool, integration_type: str, no_entity_platform: bool ) -> vol.Schema: @@ -156,6 +176,7 @@ def icon_schema( schema = vol.Schema( { + vol.Optional("conditions"): CONDITION_ICONS_SCHEMA, vol.Optional("config"): DATA_ENTRY_ICONS_SCHEMA, vol.Optional("issues"): vol.Schema( {str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}} @@ -164,6 +185,7 @@ def icon_schema( vol.Optional("services"): CORE_SERVICE_ICONS_SCHEMA if core_integration else CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA, + vol.Optional("triggers"): TRIGGER_ICONS_SCHEMA, } ) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 73505e805bc..6501aee0733 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -160,7 +160,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "anthropic", "aosmith", "apache_kafka", - "apcupsd", "apple_tv", "apprise", "aprilaire", @@ -285,7 +284,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "devialet", "device_sun_light_trigger", "devolo_home_control", - "devolo_home_network", "dexcom", "dhcp", "dialogflow", @@ -566,7 +564,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "lastfm", "launch_library", "laundrify", - "lcn", "ld2410_ble", "leaone", "led_ble", @@ -715,7 +712,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nuheat", "nuki", "numato", - "nut", "nws", "nx584", "nzbget", @@ -764,7 +760,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pandora", "panel_iframe", "peco", - "pegel_online", "pencom", "permobil", "persistent_notification", @@ -843,7 +838,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rfxtrx", "rhasspy", "ridwell", - "ring", "ripple", "risco", "rituals_perfume_genie", @@ -865,7 +859,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "ruuvitag_ble", "rympro", "saj", - "samsungtv", "sanix", "satel_integra", "schlage", @@ -929,7 +922,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "somfy_mylink", "sonarr", "songpal", - "sonos", "sony_projector", "soundtouch", "spaceapi", @@ -976,7 +968,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "tailscale", "tami4", "tank_utility", - "tankerkoenig", "tapsaff", "tasmota", "tautulli", @@ -1165,7 +1156,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "aftership", "agent_dvr", "airly", - "airgradient", "airnow", "airq", "airthings", @@ -1198,7 +1188,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "anthropic", "aosmith", "apache_kafka", - "apcupsd", "apple_tv", "apprise", "aprilaire", @@ -1325,7 +1314,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "devialet", "device_sun_light_trigger", "devolo_home_control", - "devolo_home_network", "dexcom", "dhcp", "dialogflow", @@ -1573,7 +1561,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "iqvia", "irish_rail_transport", "isal", - "ista_ecotrend", "iskra", "islamic_prayer_times", "israel_rail", @@ -1617,7 +1604,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "lametric", "launch_library", "laundrify", - "lcn", "ld2410_ble", "leaone", "led_ble", @@ -1665,13 +1651,11 @@ INTEGRATIONS_WITHOUT_SCALE = [ "manual", "manual_mqtt", "map", - "mastodon", "marytts", "matrix", "matter", "maxcube", "mazda", - "mealie", "meater", "medcom_ble", "media_extractor", @@ -1771,7 +1755,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "nuheat", "nuki", "numato", - "nut", "nws", "nx584", "nzbget", @@ -1784,7 +1767,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ombi", "omnilogic", "oncue", - "onkyo", "ondilo_ico", "onewire", "onvif", @@ -1823,7 +1805,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "palazzetti", "panel_iframe", "peco", - "pegel_online", "pencom", "permobil", "persistent_notification", @@ -1903,7 +1884,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "rfxtrx", "rhasspy", "ridwell", - "ring", "ripple", "risco", "rituals_perfume_genie", @@ -1926,7 +1906,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ruuvitag_ble", "rympro", "saj", - "samsungtv", "sanix", "satel_integra", "schlage", @@ -2042,7 +2021,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "tailwind", "tami4", "tank_utility", - "tankerkoenig", "tapsaff", "tasmota", "tautulli", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 0576a5b9b6a..a2d305f76ef 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -3,14 +3,15 @@ from __future__ import annotations from collections import deque +from collections.abc import Collection from functools import cache -from importlib.metadata import metadata +from importlib.metadata import files, metadata import json import os import re import subprocess import sys -from typing import Any +from typing import Any, TypedDict from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from tqdm import tqdm @@ -27,8 +28,10 @@ PACKAGE_CHECK_VERSION_RANGE = { "aiohttp": "SemVer", "attrs": "CalVer", "awesomeversion": "CalVer", + "bleak": "SemVer", "grpcio": "SemVer", "httpx": "SemVer", + "lxml": "SemVer", "mashumaro": "SemVer", "numpy": "SemVer", "pandas": "SemVer", @@ -41,6 +44,13 @@ PACKAGE_CHECK_VERSION_RANGE = { "urllib3": "SemVer", "yarl": "SemVer", } +PACKAGE_CHECK_PREPARE_UPDATE: dict[str, int] = { + # In the form dict("dependencyX": n+1) + # - dependencyX should be the name of the referenced dependency + # - current major version +1 + # Pandas will only fully support Python 3.14 in v3. + "pandas": 3, +} PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # In the form dict("domain": {"package": {"dependency1", "dependency2"}}) # - domain is the integration domain @@ -51,6 +61,10 @@ PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # geocachingapi > reverse_geocode > scipy > numpy "scipy": {"numpy"} }, + "noaa_tides": { + # https://github.com/GClunies/noaa_coops/pull/69 + "noaa-coops": {"pandas"} + }, } PACKAGE_REGEX = re.compile( @@ -220,11 +234,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # pymonoprice > pyserial-asyncio "pymonoprice": {"pyserial-asyncio"} }, - "mystrom": { - # https://github.com/home-assistant-ecosystem/python-mystrom/issues/55 - # python-mystrom > setuptools - "python-mystrom": {"setuptools"} - }, "nibe_heatpump": {"nibe": {"async-timeout"}}, "norway_air": {"pymetno": {"async-timeout"}}, "nx584": { @@ -245,7 +254,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # opower > arrow > types-python-dateutil "arrow": {"types-python-dateutil"} }, - "pi_hole": {"hole": {"async-timeout"}}, "pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}}, "remote_rpi_gpio": { # https://github.com/waveform80/colorzero/issues/9 @@ -263,11 +271,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "squeezebox": {"pysqueezebox": {"async-timeout"}}, "ssdp": {"async-upnp-client": {"async-timeout"}}, "surepetcare": {"surepy": {"async-timeout"}}, - "system_bridge": { - # https://github.com/timmo001/system-bridge-connector/pull/78 - # systembridgeconnector > incremental > setuptools - "incremental": {"setuptools"} - }, "travisci": { # https://github.com/menegazzo/travispy seems to be unmaintained # and unused https://www.home-assistant.io/integrations/travisci @@ -292,15 +295,72 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } +FORBIDDEN_FILE_NAMES: set[str] = { + "py.typed", # should be placed inside a package +} +FORBIDDEN_PACKAGE_NAMES: set[str] = { + "doc", + "docs", + "test", + "tests", +} +FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = { + # In the form dict("domain": {"package": {"reason1", "reason2"}}) + # - domain is the integration domain + # - package is the package (can be transitive) referencing the dependency + # - reasonX should be the name of the invalid dependency + # https://github.com/jaraco/jaraco.net + "abode": {"jaraco-abode": {"jaraco-net"}}, + # https://github.com/coinbase/coinbase-advanced-py + "coinbase": {"homeassistant": {"coinbase-advanced-py"}}, + # https://github.com/ggrammar/pizzapi + "dominos": {"homeassistant": {"pizzapi"}}, + # https://github.com/u9n/dlms-cosem + "dsmr": {"dsmr-parser": {"dlms-cosem"}}, + # https://github.com/ChrisMandich/PyFlume # Fixed with >=0.7.1 + "flume": {"homeassistant": {"pyflume"}}, + # https://github.com/fortinet-solutions-cse/fortiosapi + "fortios": {"homeassistant": {"fortiosapi"}}, + # https://github.com/manzanotti/geniushub-client + "geniushub": {"homeassistant": {"geniushub-client"}}, + # https://github.com/basnijholt/aiokef + "kef": {"homeassistant": {"aiokef"}}, + # https://github.com/danifus/pyzipper + "knx": {"xknxproject": {"pyzipper"}}, + # https://github.com/hthiery/python-lacrosse + "lacrosse": {"homeassistant": {"pylacrosse"}}, + # ??? + "linode": {"homeassistant": {"linode-api"}}, + # https://github.com/timmo001/aiolyric + "lyric": {"homeassistant": {"aiolyric"}}, + # https://github.com/microBeesTech/pythonSDK/ + "microbees": {"homeassistant": {"microbeespy"}}, + # https://github.com/tiagocoutinho/async_modbus + "nibe_heatpump": {"nibe": {"async-modbus"}}, + # https://github.com/ejpenney/pyobihai + "obihai": {"homeassistant": {"pyobihai"}}, + # https://github.com/iamkubi/pydactyl + "pterodactyl": {"homeassistant": {"py-dactyl"}}, + # https://github.com/markusressel/raspyrfm-client + "raspyrfm": {"homeassistant": {"raspyrfm-client"}}, + # https://github.com/sstallion/sensorpush-api + "sensorpush_cloud": { + "homeassistant": {"sensorpush-api"}, + "sensorpush-ha": {"sensorpush-api"}, + }, + # https://github.com/smappee/pysmappee + "smappee": {"homeassistant": {"pysmappee"}}, + # https://github.com/watergate-ai/watergate-local-api-python + "watergate": {"homeassistant": {"watergate-local-api"}}, + # https://github.com/markusressel/xs1-api-client + "xs1": {"homeassistant": {"xs1-api-client"}}, +} + PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # In the form dict("domain": {"package": {"dependency1", "dependency2"}}) # - domain is the integration domain # - package is the package (can be transitive) referencing the dependency # - dependencyX should be the name of the referenced dependency - "bluetooth": { - # https://github.com/hbldh/bleak/pull/1718 (not yet released) - "homeassistant": {"bleak"} - }, "python_script": { # Security audits are needed for each Python version "homeassistant": {"restrictedpython"} @@ -308,6 +368,16 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { } +class _PackageFilesCheckResult(TypedDict): + """Data structure to store results of package files check.""" + + top_level: set[str] + file_names: set[str] + + +_packages_checked_files_cache: dict[str, _PackageFilesCheckResult] = {} + + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle requirements for integrations.""" # Check if we are doing format-only validation. @@ -472,6 +542,12 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ) needs_forbidden_package_exceptions = False + packages_checked_files: set[str] = set() + forbidden_package_files_exceptions = FORBIDDEN_PACKAGE_FILES_EXCEPTIONS.get( + integration.domain, {} + ) + needs_forbidden_package_files_exception = False + package_version_check_exceptions = PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS.get( integration.domain, {} ) @@ -501,17 +577,9 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: continue # Check for restrictive version limits on Python - if ( - (requires_python := metadata(package)["Requires-Python"]) - and not all( - _is_dependency_version_range_valid(version_part, "SemVer") - for version_part in requires_python.split(",") - ) - # "bleak" is a transient dependency of 53 integrations, and we don't - # want to add the whole list to PYTHON_VERSION_CHECK_EXCEPTIONS - # This extra check can be removed when bleak is updated - # https://github.com/hbldh/bleak/pull/1718 - and (package in packages or package != "bleak") + if (requires_python := metadata(package)["Requires-Python"]) and not all( + _is_dependency_version_range_valid(version_part, "SemVer") + for version_part in requires_python.split(",") ): needs_python_version_check_exception = True integration.add_warning_or_error( @@ -521,6 +589,17 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: f"({requires_python}) in {package}", ) + # Check package names + if package not in packages_checked_files: + packages_checked_files.add(package) + if not check_dependency_files( + integration, + "homeassistant", + package, + forbidden_package_files_exceptions.get("homeassistant", ()), + ): + needs_forbidden_package_files_exception = True + # Use inner loop to check dependencies # so we have access to the dependency parent (=current package) dependencies: dict[str, str] = item["dependencies"] @@ -544,6 +623,17 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ): needs_package_version_check_exception = True + # Check package names + if pkg not in packages_checked_files: + packages_checked_files.add(pkg) + if not check_dependency_files( + integration, + package, + pkg, + forbidden_package_files_exceptions.get(package, ()), + ): + needs_forbidden_package_files_exception = True + to_check.extend(dependencies) if forbidden_package_exceptions and not needs_forbidden_package_exceptions: @@ -564,6 +654,15 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: f"Integration {integration.domain} version restrictions for Python have " "been resolved, please remove from `PYTHON_VERSION_CHECK_EXCEPTIONS`", ) + if ( + forbidden_package_files_exceptions + and not needs_forbidden_package_files_exception + ): + integration.add_error( + "requirements", + f"Integration {integration.domain} runtime files dependency exceptions " + "have been resolved, please remove from `FORBIDDEN_PACKAGE_FILES_EXCEPTIONS`", + ) return all_requirements @@ -583,7 +682,7 @@ def check_dependency_version_range( version == "Any" or (convention := PACKAGE_CHECK_VERSION_RANGE.get(pkg)) is None or all( - _is_dependency_version_range_valid(version_part, convention) + _is_dependency_version_range_valid(version_part, convention, pkg) for version_part in version.split(";", 1)[0].split(",") ) ): @@ -597,22 +696,35 @@ def check_dependency_version_range( return False -def _is_dependency_version_range_valid(version_part: str, convention: str) -> bool: +def _is_dependency_version_range_valid( + version_part: str, convention: str, pkg: str | None = None +) -> bool: + prepare_update = PACKAGE_CHECK_PREPARE_UPDATE.get(pkg) if pkg else None version_match = PIP_VERSION_RANGE_SEPARATOR.match(version_part.strip()) operator = version_match.group(1) version = version_match.group(2) + awesome = AwesomeVersion(version) if operator in (">", ">=", "!="): # Lower version binding and version exclusion are fine return True + if prepare_update is not None: + if operator in ("==", "~="): + # Only current major version allowed which prevents updates to the next one + return False + # Allow upper constraints for major version + 1 + if operator == "<" and awesome.section(0) < prepare_update + 1: + return False + if operator == "<=" and awesome.section(0) < prepare_update: + return False + if convention == "SemVer": if operator == "==": # Explicit version with wildcard is allowed only on major version # e.g. ==1.* is allowed, but ==1.2.* is not return version.endswith(".*") and version.count(".") == 1 - awesome = AwesomeVersion(version) if operator in ("<", "<="): # Upper version binding only allowed on major version # e.g. <=3 is allowed, but <=3.1 is not @@ -626,6 +738,43 @@ def _is_dependency_version_range_valid(version_part: str, convention: str) -> bo return False +def check_dependency_files( + integration: Integration, + package: str, + pkg: str, + package_exceptions: Collection[str], +) -> bool: + """Check dependency files for forbidden files and forbidden package names.""" + if (results := _packages_checked_files_cache.get(pkg)) is None: + top_level: set[str] = set() + file_names: set[str] = set() + for file in files(pkg) or (): + if not (top := file.parts[0].lower()).endswith((".dist-info", ".py")): + top_level.add(top) + if (name := str(file)).lower() in FORBIDDEN_FILE_NAMES: + file_names.add(name) + results = _PackageFilesCheckResult( + top_level=FORBIDDEN_PACKAGE_NAMES & top_level, + file_names=file_names, + ) + _packages_checked_files_cache[pkg] = results + if not (results["top_level"] or results["file_names"]): + return True + + for dir_name in results["top_level"]: + integration.add_warning_or_error( + pkg in package_exceptions, + "requirements", + f"Package {pkg} has a forbidden top level directory '{dir_name}' in {package}", + ) + for file_name in results["file_names"]: + integration.add_error( + "requirements", + f"Package {pkg} has a forbidden file '{file_name}' in {package}", + ) + return False + + def install_requirements(integration: Integration, requirements: set[str]) -> bool: """Install integration requirements. diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 70f0a63ca76..84d3aaefa88 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -43,104 +43,117 @@ def unique_field_validator(fields: Any) -> Any: return fields -CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( - { - vol.Optional("example"): exists, - vol.Optional("default"): exists, - vol.Optional("required"): bool, - vol.Optional("advanced"): bool, - vol.Optional(CONF_SELECTOR): selector.validate_selector, - vol.Optional("filter"): { - vol.Exclusive("attribute", "field_filter"): { - vol.Required(str): [vol.All(str, service.validate_attribute_option)], - }, - vol.Exclusive("supported_features", "field_filter"): [ - vol.All(str, service.validate_supported_feature) - ], +CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT = { + vol.Optional("description"): str, + vol.Optional("name"): str, +} + + +CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT = { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional("advanced"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, +} + +FIELD_FILTER_SCHEMA_DICT = { + vol.Optional("filter"): { + vol.Exclusive("attribute", "field_filter"): { + vol.Required(str): [vol.All(str, service.validate_attribute_option)], }, + vol.Exclusive("supported_features", "field_filter"): [ + vol.All(str, service.validate_supported_feature) + ], } -) +} -CORE_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { + +def _field_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the field schema.""" + schema_dict = CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT.copy() + + # Filters are only allowed for targeted services because they rely on the presence + # of a `target` field to determine the scope of the service call. Non-targeted + # services do not have a `target` field, making filters inapplicable. + if targeted: + schema_dict |= FIELD_FILTER_SCHEMA_DICT + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) + + +def _section_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the section schema.""" + schema_dict = { vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + vol.Required("fields"): vol.Schema( + { + str: _field_schema(targeted, custom), + } + ), } -) -CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - } -) + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT -CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + return vol.Schema(schema_dict) + + +def _service_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the service schema.""" + schema_dict = { + vol.Optional("fields"): vol.All( + vol.Schema( + { + str: vol.Any( + _field_schema(targeted, custom), + _section_schema(targeted, custom), + ), + } + ), + unique_field_validator, + ) } -) + + if targeted: + schema_dict[vol.Required("target")] = vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ) + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CORE_INTEGRATION_FIELD_SCHEMA, - CORE_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=False), + _service_schema(targeted=False, custom=False), None, ) CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CUSTOM_INTEGRATION_FIELD_SCHEMA, - CUSTOM_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=True), + _service_schema(targeted=False, custom=True), None, ) + CORE_INTEGRATION_SERVICES_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, service.starts_with_dot)): object, cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA, } ) + CUSTOM_INTEGRATION_SERVICES_SCHEMA = vol.Schema( {cv.slug: CUSTOM_INTEGRATION_SERVICE_SCHEMA} ) + VALIDATE_AS_CUSTOM_INTEGRATION = { # Adding translations would be a breaking change "foursquare", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index f4c05f504ca..d09fb27f71a 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -98,7 +98,7 @@ def find_references( continue if match := re.match(RE_REFERENCE, value): - found.append({"source": f"{prefix}::{key}", "ref": match.groups()[0]}) + found.append({"source": f"{prefix}::{key}", "ref": match.group(1)}) def removed_title_validator( @@ -306,10 +306,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), vol.Optional("selector"): cv.schema_with_slug_keys( { - "options": cv.schema_with_slug_keys( + vol.Optional("options"): cv.schema_with_slug_keys( translation_value_validator, slug_validator=translation_key_validator, - ) + ), + vol.Optional("unit_of_measurement"): cv.schema_with_slug_keys( + translation_value_validator, + slug_validator=translation_key_validator, + ), + vol.Optional("fields"): cv.schema_with_slug_keys(str), }, slug_validator=vol.Any("_", cv.slug), ), @@ -415,6 +420,38 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, slug_validator=translation_key_validator, ), + vol.Optional("conditions"): cv.schema_with_slug_keys( + { + vol.Required("name"): translation_value_validator, + vol.Required("description"): translation_value_validator, + vol.Required("description_configured"): translation_value_validator, + vol.Optional("fields"): cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Required("description"): translation_value_validator, + vol.Optional("example"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=cv.underscore_slug, + ), + vol.Optional("triggers"): cv.schema_with_slug_keys( + { + vol.Required("name"): translation_value_validator, + vol.Required("description"): translation_value_validator, + vol.Required("description_configured"): translation_value_validator, + vol.Optional("fields"): cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Required("description"): translation_value_validator, + vol.Optional("example"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=cv.underscore_slug, + ), vol.Optional("conversation"): { vol.Required("agent"): { vol.Required("done"): translation_value_validator, @@ -553,7 +590,7 @@ def validate_translation_file( "translations", "Lokalise supports only one level of references: " f'"{reference["source"]}" should point to directly ' - f'to "{match.groups()[0]}"', + f'to "{match.group(1)}"', ) diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py new file mode 100644 index 00000000000..7406e6f98ea --- /dev/null +++ b/script/hassfest/triggers.py @@ -0,0 +1,241 @@ +"""Validate triggers.""" + +from __future__ import annotations + +import contextlib +import json +import pathlib +import re +from typing import Any + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.const import CONF_SELECTOR +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, selector, trigger +from homeassistant.util.yaml import load_yaml_dict + +from .model import Config, Integration + + +def exists(value: Any) -> Any: + """Check if value exists.""" + if value is None: + raise vol.Invalid("Value cannot be None") + return value + + +FIELD_SCHEMA = vol.Schema( + { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, + } +) + +TRIGGER_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ), + vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + } + ), + None, +) + +TRIGGERS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, trigger.starts_with_dot)): object, + cv.underscore_slug: TRIGGER_SCHEMA, + } +) + +NON_MIGRATED_INTEGRATIONS = { + "calendar", + "conversation", + "device_automation", + "geo_location", + "homeassistant", + "knx", + "lg_netcast", + "litejet", + "persistent_notification", + "samsungtv", + "sun", + "tag", + "template", + "webhook", + "webostv", + "zone", + "zwave_js", +} + + +def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: + """Recursively go through a dir and it's children and find the regex.""" + pattern = re.compile(search_pattern) + + for fil in path.glob(glob_pattern): + if not fil.is_file(): + continue + + if pattern.search(fil.read_text()): + return True + + return False + + +def validate_triggers(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate triggers.""" + try: + data = load_yaml_dict(str(integration.path / "triggers.yaml")) + except FileNotFoundError: + # Find if integration uses triggers + has_triggers = grep_dir( + integration.path, + "**/trigger.py", + r"async_attach_trigger|async_get_triggers", + ) + + if has_triggers and integration.domain not in NON_MIGRATED_INTEGRATIONS: + integration.add_error( + "triggers", "Registers triggers but has no triggers.yaml" + ) + return + except HomeAssistantError: + integration.add_error("triggers", "Invalid triggers.yaml") + return + + try: + triggers = TRIGGERS_SCHEMA(data) + except vol.Invalid as err: + integration.add_error( + "triggers", f"Invalid triggers.yaml: {humanize_error(data, err)}" + ) + return + + icons_file = integration.path / "icons.json" + icons = {} + if icons_file.is_file(): + with contextlib.suppress(ValueError): + icons = json.loads(icons_file.read_text()) + trigger_icons = icons.get("triggers", {}) + + # Try loading translation strings + if integration.core: + strings_file = integration.path / "strings.json" + else: + # For custom integrations, use the en.json file + strings_file = integration.path / "translations/en.json" + + strings = {} + if strings_file.is_file(): + with contextlib.suppress(ValueError): + strings = json.loads(strings_file.read_text()) + + error_msg_suffix = "in the translations file" + if not integration.core: + error_msg_suffix = f"and is not {error_msg_suffix}" + + # For each trigger in the integration: + # 1. Check if the trigger description is set, if not, + # check if it's in the strings file else add an error. + # 2. Check if the trigger has an icon set in icons.json. + # raise an error if not., + for trigger_name, trigger_schema in triggers.items(): + if integration.core and trigger_name not in trigger_icons: + # This is enforced for Core integrations only + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no icon in icons.json.", + ) + if trigger_schema is None: + continue + if "name" not in trigger_schema and integration.core: + try: + strings["triggers"][trigger_name]["name"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no name {error_msg_suffix}", + ) + + if "description" not in trigger_schema and integration.core: + try: + strings["triggers"][trigger_name]["description"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no description {error_msg_suffix}", + ) + + # The same check is done for the description in each of the fields of the + # trigger schema. + for field_name, field_schema in trigger_schema.get("fields", {}).items(): + if "fields" in field_schema: + # This is a section + continue + if "name" not in field_schema and integration.core: + try: + strings["triggers"][trigger_name]["fields"][field_name]["name"] + except KeyError: + integration.add_error( + "triggers", + ( + f"Trigger {trigger_name} has a field {field_name} with no " + f"name {error_msg_suffix}" + ), + ) + + if "description" not in field_schema and integration.core: + try: + strings["triggers"][trigger_name]["fields"][field_name][ + "description" + ] + except KeyError: + integration.add_error( + "triggers", + ( + f"Trigger {trigger_name} has a field {field_name} with no " + f"description {error_msg_suffix}" + ), + ) + + if "selector" in field_schema: + with contextlib.suppress(KeyError): + translation_key = field_schema["selector"]["select"][ + "translation_key" + ] + try: + strings["selector"][translation_key] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file", + ) + + # The same check is done for the description in each of the sections of the + # trigger schema. + for section_name, section_schema in trigger_schema.get("fields", {}).items(): + if "fields" not in section_schema: + # This is not a section + continue + if "name" not in section_schema and integration.core: + try: + strings["triggers"][trigger_name]["sections"][section_name]["name"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has a section {section_name} with no name {error_msg_suffix}", + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle dependencies for integrations.""" + # check triggers.yaml is valid + for integration in integrations.values(): + validate_triggers(config, integration) diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py index 91c9f6a8ed0..74fd1c93be5 100644 --- a/script/install_integration_requirements.py +++ b/script/install_integration_requirements.py @@ -1,4 +1,4 @@ -"""Install requirements for a given integration.""" +"""Install requirements for one or more integrations.""" import argparse from pathlib import Path @@ -12,39 +12,49 @@ from .util import valid_integration def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" parser = argparse.ArgumentParser( - description="Install requirements for a given integration" + description="Install requirements for one or more integrations" ) parser.add_argument( - "integration", type=valid_integration, help="Integration to target." + "integrations", + nargs="+", + type=valid_integration, + help="Integration(s) to target.", ) return parser.parse_args() def main() -> int | None: - """Install requirements for a given integration.""" + """Install requirements for the specified integrations.""" if not Path("requirements_all.txt").is_file(): print("Run from project root") return 1 args = get_arguments() - requirements = gather_recursive_requirements(args.integration) + # Gather requirements for all specified integrations + all_requirements = set() + for integration in args.integrations: + requirements = gather_recursive_requirements(integration) + all_requirements.update(requirements) - cmd = [ - "uv", - "pip", - "install", - "-c", - "homeassistant/package_constraints.txt", - "-U", - *requirements, - ] - print(" ".join(cmd)) - subprocess.run( - cmd, - check=True, - ) + if all_requirements: + cmd = [ + "uv", + "pip", + "install", + "-c", + "homeassistant/package_constraints.txt", + "-U", + *sorted(all_requirements), # Sort for consistent output + ] + print(" ".join(cmd)) + subprocess.run( + cmd, + check=True, + ) + else: + print("No requirements to install.") return None diff --git a/script/licenses.py b/script/licenses.py index 3330d99b4a5..d7819cba536 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -178,7 +178,6 @@ OSI_APPROVED_LICENSES = { } EXCEPTIONS = { - "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 "chacha20poly1305", # LGPL @@ -205,11 +204,17 @@ EXCEPTIONS = { "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 } +# fmt: off TODO = { + "TravisPy": AwesomeVersion("0.3.5"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)'] "aiocache": AwesomeVersion( "0.12.3" ), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved? + "caldav": AwesomeVersion("1.6.0"), # None -- GPL -- ['GNU General Public License (GPL)', 'Apache Software License'] # https://github.com/python-caldav/caldav + "pyiskra": AwesomeVersion("0.1.21"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)'] + "xbox-webapi": AwesomeVersion("2.1.0"), # None -- GPL -- ['MIT License'] } +# fmt: on EXCEPTIONS_AND_TODOS = EXCEPTIONS.union(TODO) diff --git a/script/translations/const.py b/script/translations/const.py index 9ff8aeb2d70..18aa27b3e74 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -4,6 +4,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "v2.6.8" +CLI_2_DOCKER_IMAGE = "v2.6.14" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") diff --git a/script/translations/download.py b/script/translations/download.py index 3fa7065d058..0c9504f44cd 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -20,7 +20,7 @@ DOWNLOAD_DIR = Path("build/translations-download").absolute() def run_download_docker(): """Run the Docker image to download the translations.""" print("Running Docker to download latest translations.") - run = subprocess.run( + result = subprocess.run( [ "docker", "run", @@ -52,7 +52,7 @@ def run_download_docker(): ) print() - if run.returncode != 0: + if result.returncode != 0: raise ExitApp("Failed to download translations") diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 65bc35a5ff8..e5d3cf04a37 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -2,7 +2,7 @@ import asyncio from typing import Any -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -300,6 +300,20 @@ async def test_loading_does_not_write_right_away( assert hass_storage[auth_store.STORAGE_KEY] != {} +async def test_duplicate_uuid( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test we don't override user if we have a duplicate user ID.""" + hass_storage[auth_store.STORAGE_KEY] = MOCK_STORAGE_DATA + store = auth_store.AuthStore(hass) + await store.async_load() + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: + hex_mock.side_effect = ["user-id", "new-id"] + user = await store.async_create_user("Test User") + assert len(hex_mock.mock_calls) == 2 + assert user.id == "new-id" + + async def test_add_remove_user_affects_tokens( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: diff --git a/tests/common.py b/tests/common.py index 40d6e4d79d3..e43e4bf5fee 100644 --- a/tests/common.py +++ b/tests/common.py @@ -75,6 +75,7 @@ from homeassistant.core import ( from homeassistant.helpers import ( area_registry as ar, category_registry as cr, + condition, device_registry as dr, entity, entity_platform, @@ -87,6 +88,7 @@ from homeassistant.helpers import ( restore_state as rs, storage, translation, + trigger, ) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -295,6 +297,8 @@ async def async_test_home_assistant( # Load the registries entity.async_setup(hass) loader.async_setup(hass) + await condition.async_setup(hass) + await trigger.async_setup(hass) # setup translation cache instead of calling translation.async_setup(hass) hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache( @@ -1822,9 +1826,9 @@ def import_and_test_deprecated_constant( module.__name__, logging.WARNING, ( - f"{constant_name} was used from test_constant_deprecation," - f" this is a deprecated constant which will be removed in HA Core {breaks_in_ha_version}. " - f"Use {replacement_name} instead, please report " + f"The deprecated constant {constant_name} was used from " + "test_constant_deprecation. It will be removed in HA Core " + f"{breaks_in_ha_version}. Use {replacement_name} instead, please report " "it to the author of the 'test_constant_deprecation' custom integration" ), ) in caplog.record_tuples @@ -1856,9 +1860,9 @@ def import_and_test_deprecated_alias( module.__name__, logging.WARNING, ( - f"{alias_name} was used from test_constant_deprecation," - f" this is a deprecated alias which will be removed in HA Core {breaks_in_ha_version}. " - f"Use {replacement_name} instead, please report " + f"The deprecated alias {alias_name} was used from " + "test_constant_deprecation. It will be removed in HA Core " + f"{breaks_in_ha_version}. Use {replacement_name} instead, please report " "it to the author of the 'test_constant_deprecation' custom integration" ), ) in caplog.record_tuples diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index c7a11cb58df..9e311260693 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -21,7 +21,6 @@ 'aa:bb:cc:dd:ee:ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Acaia', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Kitchen', 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py index 7efbd1ffcdb..05d34b15ddc 100644 --- a/tests/components/ai_task/conftest.py +++ b/tests/components/ai_task/conftest.py @@ -1,5 +1,7 @@ """Test helpers for AI Task integration.""" +import json + import pytest from homeassistant.components.ai_task import ( @@ -33,7 +35,9 @@ class MockAITaskEntity(AITaskEntity): """Mock AI Task entity for testing.""" _attr_name = "Test Task Entity" - _attr_supported_features = AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + AITaskEntityFeature.GENERATE_DATA | AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) def __init__(self) -> None: """Initialize the mock entity.""" @@ -45,12 +49,18 @@ class MockAITaskEntity(AITaskEntity): ) -> GenDataTaskResult: """Mock handling of generate data task.""" self.mock_generate_data_tasks.append(task) + if task.structure is not None: + data = {"name": "Tracy Chen", "age": 30} + data_chat_log = json.dumps(data) + else: + data = "Mock result" + data_chat_log = data chat_log.async_add_assistant_content_without_tools( - AssistantContent(self.entity_id, "Mock result") + AssistantContent(self.entity_id, data_chat_log) ) return GenDataTaskResult( conversation_id=chat_log.conversation_id, - data="Mock result", + data=data, ) diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 3b40b0632a6..6986c12f8b7 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -9,13 +9,16 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Test prompt', 'role': 'user', }), dict({ 'agent_id': 'ai_task.test_task_entity', 'content': 'Mock result', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) diff --git a/tests/components/ai_task/test_entity.py b/tests/components/ai_task/test_entity.py index 3ed1c393588..08f1bb42836 100644 --- a/tests/components/ai_task/test_entity.py +++ b/tests/components/ai_task/test_entity.py @@ -1,10 +1,12 @@ """Tests for the AI Task entity model.""" from freezegun import freeze_time +import voluptuous as vol from homeassistant.components.ai_task import async_generate_data from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import selector from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -37,3 +39,40 @@ async def test_state_generate_data( assert mock_ai_task_entity.mock_generate_data_tasks task = mock_ai_task_entity.mock_generate_data_tasks[0] assert task.instructions == "Test prompt" + + +async def test_generate_structured_data( + hass: HomeAssistant, + init_components: None, + mock_config_entry: MockConfigEntry, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test the entity can generate structured data.""" + result = await async_generate_data( + hass, + task_name="Test task", + entity_id=TEST_ENTITY_ID, + instructions="Please generate a profile for a new user", + structure=vol.Schema( + { + vol.Required("name"): selector.TextSelector(), + vol.Optional("age"): selector.NumberSelector( + config=selector.NumberSelectorConfig( + min=0, + max=120, + ) + ), + } + ), + ) + # Arbitrary data returned by the mock entity (not determined by above schema in test) + assert result.data == { + "name": "Tracy Chen", + "age": 30, + } + + assert mock_ai_task_entity.mock_generate_data_tasks + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.instructions == "Please generate a profile for a new user" + assert task.structure + assert isinstance(task.structure, vol.Schema) diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index fdfaaccd0a4..09ee926c187 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -1,13 +1,20 @@ """Test initialization of the AI Task component.""" +from pathlib import Path +from typing import Any +from unittest.mock import patch + from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol +from homeassistant.components import media_source from homeassistant.components.ai_task import AITaskPreferences from homeassistant.components.ai_task.const import DATA_PREFERENCES from homeassistant.core import HomeAssistant +from homeassistant.helpers import selector -from .conftest import TEST_ENTITY_ID +from .conftest import TEST_ENTITY_ID, MockAITaskEntity from tests.common import flush_store @@ -54,7 +61,15 @@ async def test_preferences_storage_load( ), ( {}, - {"entity_id": TEST_ENTITY_ID}, + { + "entity_id": TEST_ENTITY_ID, + "attachments": [ + { + "media_content_id": "media-source://mock/blah_blah_blah.mp4", + "media_content_type": "video/mp4", + } + ], + }, ), ], ) @@ -64,21 +79,201 @@ async def test_generate_data_service( freezer: FrozenDateTimeFactory, set_preferences: dict[str, str | None], msg_extra: dict[str, str], + mock_ai_task_entity: MockAITaskEntity, ) -> None: """Test the generate data service.""" preferences = hass.data[DATA_PREFERENCES] preferences.async_set_preferences(**set_preferences) + with patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/media.mp4", + mime_type="video/mp4", + path=Path("media.mp4"), + ), + ): + result = await hass.services.async_call( + "ai_task", + "generate_data", + { + "task_name": "Test Name", + "instructions": "Test prompt", + } + | msg_extra, + blocking=True, + return_response=True, + ) + + assert result["data"] == "Mock result" + + assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 + task = mock_ai_task_entity.mock_generate_data_tasks[0] + + assert len(task.attachments or []) == len( + msg_attachments := msg_extra.get("attachments", []) + ) + + for msg_attachment, attachment in zip( + msg_attachments, task.attachments or [], strict=False + ): + assert attachment.mime_type == "video/mp4" + assert attachment.media_content_id == msg_attachment["media_content_id"] + assert attachment.path == Path("media.mp4") + + +async def test_generate_data_service_structure_fields( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test the entity can generate structured data with a top level object schema.""" result = await hass.services.async_call( "ai_task", "generate_data", { - "task_name": "Test Name", - "instructions": "Test prompt", - } - | msg_extra, + "task_name": "Profile Generation", + "instructions": "Please generate a profile for a new user", + "entity_id": TEST_ENTITY_ID, + "structure": { + "name": { + "description": "First and last name of the user such as Alice Smith", + "required": True, + "selector": {"text": {}}, + }, + "age": { + "description": "Age of the user", + "selector": { + "number": { + "min": 0, + "max": 120, + } + }, + }, + }, + }, blocking=True, return_response=True, ) + # Arbitrary data returned by the mock entity (not determined by above schema in test) + assert result["data"] == { + "name": "Tracy Chen", + "age": 30, + } - assert result["data"] == "Mock result" + assert mock_ai_task_entity.mock_generate_data_tasks + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.instructions == "Please generate a profile for a new user" + assert task.structure + assert isinstance(task.structure, vol.Schema) + schema = list(task.structure.schema.items()) + assert len(schema) == 2 + + name_key, name_value = schema[0] + assert name_key == "name" + assert isinstance(name_key, vol.Required) + assert name_key.description == "First and last name of the user such as Alice Smith" + assert isinstance(name_value, selector.TextSelector) + + age_key, age_value = schema[1] + assert age_key == "age" + assert isinstance(age_key, vol.Optional) + assert age_key.description == "Age of the user" + assert isinstance(age_value, selector.NumberSelector) + assert age_value.config["min"] == 0 + assert age_value.config["max"] == 120 + + +@pytest.mark.parametrize( + ("structure", "expected_exception", "expected_error"), + [ + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": {"invalid-selector": {}}, + }, + }, + vol.Invalid, + r"Unknown selector type invalid-selector.*", + ), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": { + "text": { + "extra-config": False, + } + }, + }, + }, + vol.Invalid, + r"extra keys not allowed.*", + ), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + }, + }, + vol.Invalid, + r"required key not provided.*selector.*", + ), + (12345, vol.Invalid, r"xpected a dictionary.*"), + ("name", vol.Invalid, r"xpected a dictionary.*"), + (["name"], vol.Invalid, r"xpected a dictionary.*"), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": {"text": {}}, + "extra-fields": "Some extra fields", + }, + }, + vol.Invalid, + r"extra keys not allowed .*", + ), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": "invalid-schema", + }, + }, + vol.Invalid, + r"xpected a dictionary for dictionary.", + ), + ], + ids=( + "invalid-selector", + "invalid-selector-config", + "missing-selector", + "structure-is-int-not-object", + "structure-is-str-not-object", + "structure-is-list-not-object", + "extra-fields", + "invalid-selector-schema", + ), +) +async def test_generate_data_service_invalid_structure( + hass: HomeAssistant, + init_components: None, + structure: Any, + expected_exception: Exception, + expected_error: str, +) -> None: + """Test the entity can generate structured data.""" + with pytest.raises(expected_exception, match=expected_error): + await hass.services.async_call( + "ai_task", + "generate_data", + { + "task_name": "Profile Generation", + "instructions": "Please generate a profile for a new user", + "entity_id": TEST_ENTITY_ID, + "structure": structure, + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index bed760c8a1d..7eb75b62bb0 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -1,28 +1,36 @@ """Test tasks for the AI Task integration.""" +from datetime import timedelta +from pathlib import Path +from unittest.mock import patch + from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import media_source from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data +from homeassistant.components.camera import Image from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator -async def test_run_task_preferred_entity( +async def test_generate_data_preferred_entity( hass: HomeAssistant, init_components: None, mock_ai_task_entity: MockAITaskEntity, hass_ws_client: WebSocketGenerator, ) -> None: - """Test running a task with an unknown entity.""" + """Test generating data with entity via preferences.""" client = await hass_ws_client(hass) with pytest.raises( @@ -90,11 +98,11 @@ async def test_run_task_preferred_entity( ) -async def test_run_data_task_unknown_entity( +async def test_generate_data_unknown_entity( hass: HomeAssistant, init_components: None, ) -> None: - """Test running a data task with an unknown entity.""" + """Test generating data with an unknown entity.""" with pytest.raises( HomeAssistantError, match="AI Task entity ai_task.unknown_entity not found" @@ -113,7 +121,7 @@ async def test_run_data_task_updates_chat_log( init_components: None, snapshot: SnapshotAssertion, ) -> None: - """Test that running a data task updates the chat log.""" + """Test that generating data updates the chat log.""" result = await async_generate_data( hass, task_name="Test Task", @@ -127,3 +135,110 @@ async def test_run_data_task_updates_chat_log( async_get_chat_log(hass, session) as chat_log, ): assert chat_log.content == snapshot + + +async def test_generate_data_attachments_not_supported( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test generating data with attachments when entity doesn't support them.""" + # Remove attachment support from the entity + mock_ai_task_entity._attr_supported_features = AITaskEntityFeature.GENERATE_DATA + + with pytest.raises( + HomeAssistantError, + match="AI Task entity ai_task.test_task_entity does not support attachments", + ): + await async_generate_data( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + attachments=[ + { + "media_content_id": "media-source://mock/test.mp4", + "media_content_type": "video/mp4", + } + ], + ) + + +async def test_generate_data_mixed_attachments( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test generating data with both camera and regular media source attachments.""" + with ( + patch( + "homeassistant.components.camera.async_get_image", + return_value=Image(content_type="image/jpeg", content=b"fake_camera_jpeg"), + ) as mock_get_image, + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/test.mp4", + mime_type="video/mp4", + path=Path("/media/test.mp4"), + ), + ) as mock_resolve_media, + ): + await async_generate_data( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Analyze these files", + attachments=[ + { + "media_content_id": "media-source://camera/camera.front_door", + "media_content_type": "image/jpeg", + }, + { + "media_content_id": "media-source://media_player/video.mp4", + "media_content_type": "video/mp4", + }, + ], + ) + + # Verify both methods were called + mock_get_image.assert_called_once_with(hass, "camera.front_door") + mock_resolve_media.assert_called_once_with( + hass, "media-source://media_player/video.mp4", None + ) + + # Check attachments + assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.attachments is not None + assert len(task.attachments) == 2 + + # Check camera attachment + camera_attachment = task.attachments[0] + assert ( + camera_attachment.media_content_id == "media-source://camera/camera.front_door" + ) + assert camera_attachment.mime_type == "image/jpeg" + assert isinstance(camera_attachment.path, Path) + assert camera_attachment.path.suffix == ".jpg" + + # Verify camera snapshot content + assert camera_attachment.path.exists() + content = await hass.async_add_executor_job(camera_attachment.path.read_bytes) + assert content == b"fake_camera_jpeg" + + # Trigger clean up + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), + ) + await hass.async_block_till_done() + + # Verify the temporary file cleaned up + assert not camera_attachment.path.exists() + + # Check regular media attachment + media_attachment = task.attachments[1] + assert media_attachment.media_content_id == "media-source://media_player/video.mp4" + assert media_attachment.mime_type == "video/mp4" + assert media_attachment.path == Path("/media/test.mp4") diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index b3181fddfeb..2a1e3dcc7fd 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -21,7 +21,6 @@ '84fce612f5b8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'AirGradient', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '84fce612f5b8', - 'suggested_area': None, 'sw_version': '3.1.1', 'via_device_id': None, }) @@ -58,7 +56,6 @@ '84fce612f5b8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'AirGradient', @@ -68,7 +65,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '84fce612f5b8', - 'suggested_area': None, 'sw_version': '3.1.1', 'via_device_id': None, }) diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 575c596404b..e205e626ab8 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -622,7 +622,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-pm01', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[indoor][sensor.airgradient_pm1-state] @@ -631,7 +631,7 @@ 'device_class': 'pm1', 'friendly_name': 'Airgradient PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airgradient_pm1', @@ -675,7 +675,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-pm10', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[indoor][sensor.airgradient_pm10-state] @@ -684,7 +684,7 @@ 'device_class': 'pm10', 'friendly_name': 'Airgradient PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airgradient_pm10', @@ -728,7 +728,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '84fce612f5b8-pm02', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[indoor][sensor.airgradient_pm2_5-state] @@ -737,7 +737,7 @@ 'device_class': 'pm25', 'friendly_name': 'Airgradient PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airgradient_pm2_5', @@ -833,7 +833,7 @@ 'supported_features': 0, 'translation_key': 'raw_pm02', 'unique_id': '84fce612f5b8-pm02_raw', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-state] @@ -842,7 +842,7 @@ 'device_class': 'pm25', 'friendly_name': 'Airgradient Raw PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airgradient_raw_pm2_5', diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index efd809e76ae..8d79f8cdf0a 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -36,7 +36,7 @@ 'supported_features': 0, 'translation_key': 'co', 'unique_id': '123-456-co', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_carbon_monoxide-state] @@ -47,7 +47,7 @@ 'limit': 4000, 'percent': 4, 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_carbon_monoxide', @@ -207,7 +207,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-no2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_nitrogen_dioxide-state] @@ -219,7 +219,7 @@ 'limit': 25, 'percent': 64, 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_nitrogen_dioxide', @@ -266,7 +266,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-o3', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_ozone-state] @@ -278,7 +278,7 @@ 'limit': 100, 'percent': 42, 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_ozone', @@ -325,7 +325,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-pm1', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_pm1-state] @@ -335,7 +335,7 @@ 'device_class': 'pm1', 'friendly_name': 'Home PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_pm1', @@ -382,7 +382,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-pm10', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_pm10-state] @@ -394,7 +394,7 @@ 'limit': 45, 'percent': 14, 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_pm10', @@ -441,7 +441,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-pm25', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_pm2_5-state] @@ -453,7 +453,7 @@ 'limit': 15, 'percent': 29, 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_pm2_5', @@ -557,7 +557,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-456-so2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_sulphur_dioxide-state] @@ -569,7 +569,7 @@ 'limit': 40, 'percent': 35, 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_sulphur_dioxide', diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 73ba6a7123f..d711f9c2eba 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -12,7 +12,7 @@ 'Longitude': '**REDACTED**', 'O3': 0.048, 'PM10': 12, - 'PM2.5': 8.9, + 'PM2.5': 6.7, 'Pollutant': 'O3', 'ReportingArea': '**REDACTED**', 'StateCode': '**REDACTED**', diff --git a/tests/components/airos/__init__.py b/tests/components/airos/__init__.py new file mode 100644 index 00000000000..f663644a8a4 --- /dev/null +++ b/tests/components/airos/__init__.py @@ -0,0 +1,19 @@ +"""Tests for the Ubiquity airOS integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, patch + + +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform] | None = None, +) -> None: + """Fixture for setting up the component.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.airos._PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py new file mode 100644 index 00000000000..5443f79a976 --- /dev/null +++ b/tests/components/airos/conftest.py @@ -0,0 +1,61 @@ +"""Common fixtures for the Ubiquiti airOS tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from airos.airos8 import AirOSData +import pytest + +from homeassistant.components.airos.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def ap_fixture(): + """Load fixture data for AP mode.""" + json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN) + return AirOSData.from_dict(json_data) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airos.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_airos_client( + request: pytest.FixtureRequest, ap_fixture: AirOSData +) -> Generator[AsyncMock]: + """Fixture to mock the AirOS API client.""" + with ( + patch( + "homeassistant.components.airos.config_flow.AirOS", autospec=True + ) as mock_airos, + patch("homeassistant.components.airos.coordinator.AirOS", new=mock_airos), + patch("homeassistant.components.airos.AirOS", new=mock_airos), + ): + client = mock_airos.return_value + client.status.return_value = ap_fixture + client.login.return_value = True + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the AirOS mocked config entry.""" + return MockConfigEntry( + title="NanoStation", + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "ubnt", + }, + unique_id="01:23:45:67:89:AB", + ) diff --git a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json new file mode 100644 index 00000000000..06feb3d0a55 --- /dev/null +++ b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json @@ -0,0 +1,354 @@ +{ + "chain_names": [ + { + "name": "Chain 0", + "number": 1 + }, + { + "name": "Chain 1", + "number": 2 + } + ], + "derived": { + "access_point": true, + "mac": "01:23:45:67:89:AB", + "mac_interface": "br0", + "mode": "point_to_point", + "ptmp": false, + "ptp": true, + "role": "access_point", + "station": false + }, + "firewall": { + "eb6tables": false, + "ebtables": false, + "ip6tables": false, + "iptables": false + }, + "genuine": "/images/genuine.png", + "gps": { + "alt": null, + "dim": null, + "dop": null, + "fix": 0, + "lat": 52.379894, + "lon": 4.901608, + "sats": null, + "time_synced": null + }, + "host": { + "cpuload": 10.10101, + "device_id": "03aa0d0b40fed0a47088293584ef5432", + "devmodel": "NanoStation 5AC loco", + "freeram": 16564224, + "fwversion": "v8.7.17", + "height": 3, + "hostname": "NanoStation 5AC ap name", + "loadavg": 0.412598, + "netrole": "bridge", + "power_time": 268683, + "temperature": 0, + "time": "2025-06-23 23:06:42", + "timestamp": 2668313184, + "totalram": 63447040, + "uptime": 264888 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "eth0", + "mtu": 1500, + "status": { + "cable_len": 18, + "duplex": true, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": true, + "rx_bytes": 3984971949, + "rx_dropped": 0, + "rx_errors": 4, + "rx_packets": 73564835, + "snr": [30, 30, 30, 30], + "speed": 1000, + "tx_bytes": 209900085624, + "tx_dropped": 10, + "tx_errors": 0, + "tx_packets": 185866883 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "ath0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": false, + "rx_bytes": 206938324766, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 149767200, + "snr": null, + "speed": 0, + "tx_bytes": 5265602738, + "tx_dropped": 2005, + "tx_errors": 0, + "tx_packets": 52980390 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "br0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": [ + { + "addr": "fe80::eea:14ff:fea4:89cd", + "plen": 64 + } + ], + "ipaddr": "192.168.1.2", + "plugged": true, + "rx_bytes": 204802727, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 1791592, + "snr": null, + "speed": 0, + "tx_bytes": 236295176, + "tx_dropped": 0, + "tx_errors": 0, + "tx_packets": 298119 + } + } + ], + "ntpclient": {}, + "portfw": false, + "provmode": {}, + "services": { + "airview": 2, + "dhcp6d_stateful": false, + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 0, + "timestamp": null + }, + "wireless": { + "antenna_gain": 13, + "apmac": "01:23:45:67:89:AB", + "aprepeater": false, + "band": 2, + "cac_state": 0, + "cac_timeout": 0, + "center1_freq": 5530, + "chanbw": 80, + "compat_11n": 0, + "count": 1, + "dfs": 1, + "distance": 0, + "essid": "DemoSSID", + "frequency": 5500, + "hide_essid": 0, + "ieeemode": "11ACVHT80", + "mode": "ap-ptp", + "noisef": -89, + "nol_state": 0, + "nol_timeout": 0, + "polling": { + "atpc_status": 2, + "cb_capacity": 593970, + "dl_capacity": 647400, + "ff_cap_rep": false, + "fixed_frame": false, + "flex_mode": null, + "gps_sync": false, + "rx_use": 42, + "tx_use": 6, + "ul_capacity": 540540, + "use": 48 + }, + "rstatus": 5, + "rx_chainmask": 3, + "rx_idx": 8, + "rx_nss": 2, + "security": "WPA2", + "service": { + "link": 266003, + "time": 267181 + }, + "sta": [ + { + "airmax": { + "actual_priority": 0, + "atpc_status": 2, + "beam": 0, + "cb_capacity": 593970, + "desired_priority": 0, + "dl_capacity": 647400, + "rx": { + "cinr": 31, + "evm": [ + [ + 31, 28, 33, 32, 32, 32, 31, 31, 31, 29, 30, 32, 30, 27, 34, 31, + 31, 30, 32, 29, 31, 29, 31, 33, 31, 31, 32, 30, 31, 34, 33, 31, + 30, 31, 30, 31, 31, 32, 31, 30, 33, 31, 30, 31, 27, 31, 30, 30, + 30, 30, 30, 29, 32, 34, 31, 30, 28, 30, 29, 35, 31, 33, 32, 29 + ], + [ + 34, 34, 35, 34, 35, 35, 34, 34, 34, 34, 34, 34, 34, 34, 35, 35, + 34, 34, 35, 34, 33, 33, 35, 34, 34, 35, 34, 35, 34, 34, 35, 34, + 34, 33, 34, 34, 34, 34, 34, 35, 35, 35, 34, 35, 33, 34, 34, 34, + 34, 35, 35, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 35, 35 + ] + ], + "usage": 42 + }, + "tx": { + "cinr": 31, + "evm": [ + [ + 32, 34, 28, 33, 35, 30, 31, 33, 30, 30, 32, 30, 29, 33, 31, 29, + 33, 31, 31, 30, 33, 34, 33, 31, 33, 32, 32, 31, 29, 31, 30, 32, + 31, 30, 29, 32, 31, 32, 31, 31, 32, 29, 31, 29, 30, 32, 32, 31, + 32, 32, 33, 31, 28, 29, 31, 31, 33, 32, 33, 32, 32, 32, 31, 33 + ], + [ + 37, 37, 37, 38, 38, 37, 36, 38, 38, 37, 37, 37, 37, 37, 39, 37, + 37, 37, 37, 37, 37, 36, 37, 37, 37, 37, 37, 37, 37, 38, 37, 37, + 38, 37, 37, 37, 38, 37, 38, 37, 37, 37, 37, 37, 36, 37, 37, 37, + 37, 37, 37, 38, 37, 37, 38, 37, 36, 37, 37, 37, 37, 37, 37, 37 + ] + ], + "usage": 6 + }, + "ul_capacity": 540540 + }, + "airos_connected": true, + "cb_capacity_expect": 416000, + "chainrssi": [35, 32, 0], + "distance": 1, + "dl_avg_linkscore": 100, + "dl_capacity_expect": 208000, + "dl_linkscore": 100, + "dl_rate_expect": 3, + "dl_signal_expect": -80, + "last_disc": 1, + "lastip": "192.168.1.2", + "mac": "01:23:45:67:89:AB", + "noisefloor": -89, + "remote": { + "age": 1, + "airview": 2, + "antenna_gain": 13, + "cable_loss": 0, + "chainrssi": [33, 37, 0], + "compat_11n": 0, + "cpuload": 43.564301, + "device_id": "d4f4cdf82961e619328a8f72f8d7653b", + "distance": 1, + "ethlist": [ + { + "cable_len": 14, + "duplex": true, + "enabled": true, + "ifname": "eth0", + "plugged": true, + "snr": [30, 30, 29, 30], + "speed": 1000 + } + ], + "freeram": 14290944, + "gps": { + "alt": null, + "dim": null, + "dop": null, + "fix": 0, + "lat": 52.379894, + "lon": 4.901608, + "sats": null, + "time_synced": null + }, + "height": 2, + "hostname": "NanoStation 5AC sta name", + "ip6addr": ["fe80::eea:14ff:fea4:89ab"], + "ipaddr": ["192.168.1.2"], + "mode": "sta-ptp", + "netrole": "bridge", + "noisefloor": -90, + "oob": false, + "platform": "NanoStation 5AC loco", + "power_time": 268512, + "rssi": 38, + "rx_bytes": 3624206478, + "rx_chainmask": 3, + "rx_throughput": 251, + "service": { + "link": 265996, + "time": 267195 + }, + "signal": -58, + "sys_id": "0xe7fa", + "temperature": 0, + "time": "2025-06-23 23:13:54", + "totalram": 63447040, + "tx_bytes": 212308148210, + "tx_power": -4, + "tx_ratedata": [ + 14, 4, 372, 2223, 4708, 4037, 8142, 485763, 29420892, 24748154 + ], + "tx_throughput": 16023, + "unms": { + "status": 0, + "timestamp": null + }, + "uptime": 265320, + "version": "WA.ar934x.v8.7.17.48152.250620.2132" + }, + "rssi": 37, + "rx_idx": 8, + "rx_nss": 2, + "signal": -59, + "stats": { + "rx_bytes": 206938324814, + "rx_packets": 149767200, + "rx_pps": 846, + "tx_bytes": 5265602739, + "tx_packets": 52980390, + "tx_pps": 0 + }, + "tx_idx": 9, + "tx_latency": 0, + "tx_lretries": 0, + "tx_nss": 2, + "tx_packets": 0, + "tx_ratedata": [175, 4, 47, 200, 673, 158, 163, 138, 68895, 19577430], + "tx_sretries": 0, + "ul_avg_linkscore": 88, + "ul_capacity_expect": 624000, + "ul_linkscore": 86, + "ul_rate_expect": 8, + "ul_signal_expect": -55, + "uptime": 170281 + } + ], + "sta_disconnected": [], + "throughput": { + "rx": 9907, + "tx": 222 + }, + "tx_chainmask": 3, + "tx_idx": 9, + "tx_nss": 2, + "txpower": -3 + } +} diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..d9815e0c62b --- /dev/null +++ b/tests/components/airos/snapshots/test_binary_sensor.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_client', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP client', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_client', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_client', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCP client', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_server', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP server', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_server', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_server', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCP server', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_server', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcpv6_server', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCPv6 server', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp6_server', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp6_server', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCPv6 server', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcpv6_server', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_port_forwarding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Port forwarding', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_forwarding', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_portfw', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name Port forwarding', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_port_forwarding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_pppoe_link', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PPPoE link', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pppoe', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_pppoe', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'NanoStation 5AC ap name PPPoE link', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_pppoe_link', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f4561ec6d99 --- /dev/null +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -0,0 +1,640 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'chain_names': list([ + dict({ + 'name': 'Chain 0', + 'number': 1, + }), + dict({ + 'name': 'Chain 1', + 'number': 2, + }), + ]), + 'derived': dict({ + 'access_point': True, + 'mac': '**REDACTED**', + 'mac_interface': 'br0', + 'mode': 'point_to_point', + 'ptmp': False, + 'ptp': True, + 'role': 'access_point', + 'station': False, + }), + 'firewall': dict({ + 'eb6tables': False, + 'ebtables': False, + 'ip6tables': False, + 'iptables': False, + }), + 'genuine': '/images/genuine.png', + 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, + }), + 'host': dict({ + 'cpuload': 10.10101, + 'device_id': '03aa0d0b40fed0a47088293584ef5432', + 'devmodel': 'NanoStation 5AC loco', + 'freeram': 16564224, + 'fwversion': 'v8.7.17', + 'height': 3, + 'hostname': '**REDACTED**', + 'loadavg': 0.412598, + 'netrole': 'bridge', + 'power_time': 268683, + 'temperature': 0, + 'time': '2025-06-23 23:06:42', + 'timestamp': 2668313184, + 'totalram': 63447040, + 'uptime': 264888, + }), + 'interfaces': list([ + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'eth0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': 18, + 'duplex': True, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': True, + 'rx_bytes': 3984971949, + 'rx_dropped': 0, + 'rx_errors': 4, + 'rx_packets': 73564835, + 'snr': list([ + 30, + 30, + 30, + 30, + ]), + 'speed': 1000, + 'tx_bytes': 209900085624, + 'tx_dropped': 10, + 'tx_errors': 0, + 'tx_packets': 185866883, + }), + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'ath0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': False, + 'rx_bytes': 206938324766, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 149767200, + 'snr': None, + 'speed': 0, + 'tx_bytes': 5265602738, + 'tx_dropped': 2005, + 'tx_errors': 0, + 'tx_packets': 52980390, + }), + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'br0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, + 'ip6addr': '**REDACTED**', + 'ipaddr': '**REDACTED**', + 'plugged': True, + 'rx_bytes': 204802727, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 1791592, + 'snr': None, + 'speed': 0, + 'tx_bytes': 236295176, + 'tx_dropped': 0, + 'tx_errors': 0, + 'tx_packets': 298119, + }), + }), + ]), + 'ntpclient': dict({ + }), + 'portfw': False, + 'provmode': dict({ + }), + 'services': dict({ + 'airview': 2, + 'dhcp6d_stateful': False, + 'dhcpc': False, + 'dhcpd': False, + 'pppoe': False, + }), + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'wireless': dict({ + 'antenna_gain': 13, + 'apmac': '**REDACTED**', + 'aprepeater': False, + 'band': 2, + 'cac_state': 0, + 'cac_timeout': 0, + 'center1_freq': 5530, + 'chanbw': 80, + 'compat_11n': 0, + 'count': 1, + 'dfs': 1, + 'distance': 0, + 'essid': '**REDACTED**', + 'frequency': 5500, + 'hide_essid': 0, + 'ieeemode': '11ACVHT80', + 'mode': 'ap-ptp', + 'noisef': -89, + 'nol_state': 0, + 'nol_timeout': 0, + 'polling': dict({ + 'atpc_status': 2, + 'cb_capacity': 593970, + 'dl_capacity': 647400, + 'ff_cap_rep': False, + 'fixed_frame': False, + 'flex_mode': None, + 'gps_sync': False, + 'rx_use': 42, + 'tx_use': 6, + 'ul_capacity': 540540, + 'use': 48, + }), + 'rstatus': 5, + 'rx_chainmask': 3, + 'rx_idx': 8, + 'rx_nss': 2, + 'security': 'WPA2', + 'service': dict({ + 'link': 266003, + 'time': 267181, + }), + 'sta': list([ + dict({ + 'airmax': dict({ + 'actual_priority': 0, + 'atpc_status': 2, + 'beam': 0, + 'cb_capacity': 593970, + 'desired_priority': 0, + 'dl_capacity': 647400, + 'rx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 31, + 28, + 33, + 32, + 32, + 32, + 31, + 31, + 31, + 29, + 30, + 32, + 30, + 27, + 34, + 31, + 31, + 30, + 32, + 29, + 31, + 29, + 31, + 33, + 31, + 31, + 32, + 30, + 31, + 34, + 33, + 31, + 30, + 31, + 30, + 31, + 31, + 32, + 31, + 30, + 33, + 31, + 30, + 31, + 27, + 31, + 30, + 30, + 30, + 30, + 30, + 29, + 32, + 34, + 31, + 30, + 28, + 30, + 29, + 35, + 31, + 33, + 32, + 29, + ]), + list([ + 34, + 34, + 35, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 35, + 34, + 33, + 33, + 35, + 34, + 34, + 35, + 34, + 35, + 34, + 34, + 35, + 34, + 34, + 33, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 35, + 34, + 35, + 33, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + ]), + ]), + 'usage': 42, + }), + 'tx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 32, + 34, + 28, + 33, + 35, + 30, + 31, + 33, + 30, + 30, + 32, + 30, + 29, + 33, + 31, + 29, + 33, + 31, + 31, + 30, + 33, + 34, + 33, + 31, + 33, + 32, + 32, + 31, + 29, + 31, + 30, + 32, + 31, + 30, + 29, + 32, + 31, + 32, + 31, + 31, + 32, + 29, + 31, + 29, + 30, + 32, + 32, + 31, + 32, + 32, + 33, + 31, + 28, + 29, + 31, + 31, + 33, + 32, + 33, + 32, + 32, + 32, + 31, + 33, + ]), + list([ + 37, + 37, + 37, + 38, + 38, + 37, + 36, + 38, + 38, + 37, + 37, + 37, + 37, + 37, + 39, + 37, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 37, + 37, + 38, + 37, + 38, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + ]), + ]), + 'usage': 6, + }), + 'ul_capacity': 540540, + }), + 'airos_connected': True, + 'cb_capacity_expect': 416000, + 'chainrssi': list([ + 35, + 32, + 0, + ]), + 'distance': 1, + 'dl_avg_linkscore': 100, + 'dl_capacity_expect': 208000, + 'dl_linkscore': 100, + 'dl_rate_expect': 3, + 'dl_signal_expect': -80, + 'last_disc': 1, + 'lastip': '**REDACTED**', + 'mac': '**REDACTED**', + 'noisefloor': -89, + 'remote': dict({ + 'age': 1, + 'airview': 2, + 'antenna_gain': 13, + 'cable_loss': 0, + 'chainrssi': list([ + 33, + 37, + 0, + ]), + 'compat_11n': 0, + 'cpuload': 43.564301, + 'device_id': 'd4f4cdf82961e619328a8f72f8d7653b', + 'distance': 1, + 'ethlist': list([ + dict({ + 'cable_len': 14, + 'duplex': True, + 'enabled': True, + 'ifname': 'eth0', + 'plugged': True, + 'snr': list([ + 30, + 30, + 29, + 30, + ]), + 'speed': 1000, + }), + ]), + 'freeram': 14290944, + 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, + }), + 'height': 2, + 'hostname': '**REDACTED**', + 'ip6addr': '**REDACTED**', + 'ipaddr': '**REDACTED**', + 'mode': 'sta-ptp', + 'netrole': 'bridge', + 'noisefloor': -90, + 'oob': False, + 'platform': 'NanoStation 5AC loco', + 'power_time': 268512, + 'rssi': 38, + 'rx_bytes': 3624206478, + 'rx_chainmask': 3, + 'rx_throughput': 251, + 'service': dict({ + 'link': 265996, + 'time': 267195, + }), + 'signal': -58, + 'sys_id': '0xe7fa', + 'temperature': 0, + 'time': '2025-06-23 23:13:54', + 'totalram': 63447040, + 'tx_bytes': 212308148210, + 'tx_power': -4, + 'tx_ratedata': list([ + 14, + 4, + 372, + 2223, + 4708, + 4037, + 8142, + 485763, + 29420892, + 24748154, + ]), + 'tx_throughput': 16023, + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'uptime': 265320, + 'version': 'WA.ar934x.v8.7.17.48152.250620.2132', + }), + 'rssi': 37, + 'rx_idx': 8, + 'rx_nss': 2, + 'signal': -59, + 'stats': dict({ + 'rx_bytes': 206938324814, + 'rx_packets': 149767200, + 'rx_pps': 846, + 'tx_bytes': 5265602739, + 'tx_packets': 52980390, + 'tx_pps': 0, + }), + 'tx_idx': 9, + 'tx_latency': 0, + 'tx_lretries': 0, + 'tx_nss': 2, + 'tx_packets': 0, + 'tx_ratedata': list([ + 175, + 4, + 47, + 200, + 673, + 158, + 163, + 138, + 68895, + 19577430, + ]), + 'tx_sretries': 0, + 'ul_avg_linkscore': 88, + 'ul_capacity_expect': 624000, + 'ul_linkscore': 86, + 'ul_rate_expect': 8, + 'ul_signal_expect': -55, + 'uptime': 170281, + }), + ]), + 'sta_disconnected': list([ + ]), + 'throughput': dict({ + 'rx': 9907, + 'tx': 222, + }), + 'tx_chainmask': 3, + 'tx_idx': 9, + 'tx_nss': 2, + 'txpower': -3, + }), + }), + 'entry_data': dict({ + 'host': '**REDACTED**', + 'password': '**REDACTED**', + 'username': 'ubnt', + }), + }) +# --- diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..815b11ddc7e --- /dev/null +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -0,0 +1,732 @@ +# serializer version: 1 +# name: test_all_entities[sensor.nanostation_5ac_ap_name_antenna_gain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_antenna_gain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Antenna gain', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_antenna_gain', + 'unique_id': '01:23:45:67:89:AB_wireless_antenna_gain', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_antenna_gain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'NanoStation 5AC ap name Antenna gain', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_antenna_gain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_cpu_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_cpu_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU load', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_cpuload', + 'unique_id': '01:23:45:67:89:AB_host_cpuload', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_cpu_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name CPU load', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_cpu_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.10101', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_download_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download capacity', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_polling_dl_capacity', + 'unique_id': '01:23:45:67:89:AB_wireless_polling_dl_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_download_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Download capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '647.4', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'bridge', + 'router', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_network_role', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Network role', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_netrole', + 'unique_id': '01:23:45:67:89:AB_host_netrole', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Network role', + 'options': list([ + 'bridge', + 'router', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_network_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bridge', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_receive_actual-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_receive_actual', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Throughput receive (actual)', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_throughput_rx', + 'unique_id': '01:23:45:67:89:AB_wireless_throughput_rx', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_receive_actual-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Throughput receive (actual)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_receive_actual', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.907', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_transmit_actual', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Throughput transmit (actual)', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_throughput_tx', + 'unique_id': '01:23:45:67:89:AB_wireless_throughput_tx', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Throughput transmit (actual)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_transmit_actual', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.222', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload capacity', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_polling_ul_capacity', + 'unique_id': '01:23:45:67:89:AB_wireless_polling_ul_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Upload capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '540.54', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_uptime', + 'unique_id': '01:23:45:67:89:AB_host_uptime', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'NanoStation 5AC ap name Uptime', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.06583333333333', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless distance', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_distance', + 'unique_id': '01:23:45:67:89:AB_wireless_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'NanoStation 5AC ap name Wireless distance', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless frequency', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_frequency', + 'unique_id': '01:23:45:67:89:AB_wireless_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'NanoStation 5AC ap name Wireless frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5500', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'point_to_point', + 'point_to_multipoint', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless mode', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_mode', + 'unique_id': '01:23:45:67:89:AB_wireless_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Wireless mode', + 'options': list([ + 'point_to_point', + 'point_to_multipoint', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'point_to_point', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'station', + 'access_point', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_role', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless role', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_role', + 'unique_id': '01:23:45:67:89:AB_wireless_role', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Wireless role', + 'options': list([ + 'station', + 'access_point', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'access_point', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wireless SSID', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_essid', + 'unique_id': '01:23:45:67:89:AB_wireless_essid', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name Wireless SSID', + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'DemoSSID', + }) +# --- diff --git a/tests/components/airos/test_binary_sensor.py b/tests/components/airos/test_binary_sensor.py new file mode 100644 index 00000000000..40c3d631cd3 --- /dev/null +++ b/tests/components/airos/test_binary_sensor.py @@ -0,0 +1,28 @@ +"""Test the Ubiquiti airOS binary sensors.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py new file mode 100644 index 00000000000..212c80dfc2b --- /dev/null +++ b/tests/components/airos/test_config_flow.py @@ -0,0 +1,119 @@ +"""Test the Ubiquiti airOS config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +from airos.exceptions import ( + AirOSConnectionAuthenticationError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, +) +import pytest + +from homeassistant.components.airos.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", +} + + +async def test_form_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_airos_client: AsyncMock, + ap_fixture: dict[str, Any], +) -> None: + """Test we get the form and create the appropriate entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "NanoStation 5AC ap name" + assert result["result"].unique_id == "01:23:45:67:89:AB" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_duplicate_entry( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test the form does not allow duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AirOSConnectionAuthenticationError, "invalid_auth"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), + (Exception, "unknown"), + ], +) +async def test_form_exception_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_airos_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle exceptions.""" + mock_airos_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_airos_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "NanoStation 5AC ap name" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/airos/test_diagnostics.py similarity index 50% rename from tests/components/linear_garage_door/test_diagnostics.py rename to tests/components/airos/test_diagnostics.py index f51bb0a366c..453e8ff1f03 100644 --- a/tests/components/linear_garage_door/test_diagnostics.py +++ b/tests/components/airos/test_diagnostics.py @@ -1,10 +1,10 @@ -"""Test diagnostics of Linear Garage Door.""" +"""Diagnostic tests for airOS.""" -from unittest.mock import AsyncMock +from unittest.mock import MagicMock from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props +from homeassistant.components.airos.coordinator import AirOSData from homeassistant.core import HomeAssistant from . import setup_integration @@ -14,16 +14,19 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -async def test_entry_diagnostics( +async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, - mock_linear: AsyncMock, + mock_airos_client: MagicMock, mock_config_entry: MockConfigEntry, + ap_fixture: AirOSData, + snapshot: SnapshotAssertion, ) -> None: - """Test config entry diagnostics.""" - await setup_integration(hass, mock_config_entry, []) - result = await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry + """Test diagnostics.""" + + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot ) - assert result == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py new file mode 100644 index 00000000000..7f39f504753 --- /dev/null +++ b/tests/components/airos/test_sensor.py @@ -0,0 +1,85 @@ +"""Test the Ubiquiti airOS sensors.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from airos.exceptions import ( + AirOSConnectionAuthenticationError, + AirOSDataMissingError, + AirOSDeviceConnectionError, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.airos.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("exception"), + [ + AirOSConnectionAuthenticationError, + TimeoutError, + AirOSDeviceConnectionError, + AirOSDataMissingError, + ], +) +async def test_sensor_update_exception_handling( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity update data handles exceptions.""" + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + expected_entity_id = "sensor.nanostation_5ac_ap_name_antenna_gain" + signal_state = hass.states.get(expected_entity_id) + + assert signal_state.state == "13", f"Expected state 13, got {signal_state.state}" + assert signal_state.attributes.get("unit_of_measurement") == "dB", ( + f"Expected unit 'dB', got {signal_state.attributes.get('unit_of_measurement')}" + ) + + mock_airos_client.login.side_effect = exception + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds())) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + signal_state = hass.states.get(expected_entity_id) + + assert signal_state.state == STATE_UNAVAILABLE, ( + f"Expected state {STATE_UNAVAILABLE}, got {signal_state.state}" + ) + + mock_airos_client.login.side_effect = None + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds())) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + signal_state = hass.states.get(expected_entity_id) + assert signal_state.state == "13", f"Expected state 13, got {signal_state.state}" diff --git a/tests/components/airq/__init__.py b/tests/components/airq/__init__.py index 612761c0653..41bc1e467dc 100644 --- a/tests/components/airq/__init__.py +++ b/tests/components/airq/__init__.py @@ -1 +1,32 @@ """Tests for the air-Q integration.""" + +from unittest.mock import patch + +from homeassistant.components.airq.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .common import TEST_DEVICE_INFO, TEST_USER_DATA + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platform: Platform) -> None: + """Load AirQ integration. + + This function does not patch AirQ itself, rather it depends on being + run in presence of `mock_coordinator_airq` fixture, which patches calls + by `AirQCoordinator.airq`, which are done under `async_setup`. + + Patching airq.PLATFORMS allows to set up a single platform in isolation. + """ + config_entry = MockConfigEntry( + domain=DOMAIN, data=TEST_USER_DATA, unique_id=TEST_DEVICE_INFO["id"] + ) + config_entry.add_to_hass(hass) + + # The patching is now handled by the mock_airq fixture. + # We just need to load the component. + with patch("homeassistant.components.airq.PLATFORMS", [platform]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airq/common.py b/tests/components/airq/common.py new file mode 100644 index 00000000000..2e568c3c3cb --- /dev/null +++ b/tests/components/airq/common.py @@ -0,0 +1,19 @@ +"""Common methods used across tests for air-Q.""" + +from aioairq import DeviceInfo + +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD + +TEST_USER_DATA = { + CONF_IP_ADDRESS: "192.168.0.0", + CONF_PASSWORD: "password", +} +TEST_DEVICE_INFO = DeviceInfo( + id="id", + name="name", + model="model", + sw_version="sw", + hw_version="hw", +) +TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} +TEST_BRIGHTNESS = 42 diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py index a132153a76f..21118c3ef27 100644 --- a/tests/components/airq/conftest.py +++ b/tests/components/airq/conftest.py @@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, patch import pytest +from .common import TEST_BRIGHTNESS, TEST_DEVICE_DATA, TEST_DEVICE_INFO + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +15,28 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.airq.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_airq(): + """Mock the aioairq.AirQ object. + + The integration imports it in two places: in coordinator and config_flow. + """ + + with ( + patch( + "homeassistant.components.airq.coordinator.AirQ", + autospec=True, + ) as mock_airq_class, + patch( + "homeassistant.components.airq.config_flow.AirQ", + new=mock_airq_class, + ), + ): + airq = mock_airq_class.return_value + # Pre-configure default mock values for setup + airq.fetch_device_info = AsyncMock(return_value=TEST_DEVICE_INFO) + airq.get_latest_data = AsyncMock(return_value=TEST_DEVICE_DATA) + airq.get_current_brightness = AsyncMock(return_value=TEST_BRIGHTNESS) + yield airq diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 09da6343e05..66cacecdaaa 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,9 +1,9 @@ """Test the air-Q config flow.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock -from aioairq import DeviceInfo, InvalidAuth +from aioairq import InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import pytest @@ -13,32 +13,27 @@ from homeassistant.components.airq.const import ( CONF_RETURN_AVERAGE, DOMAIN, ) -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .common import TEST_DEVICE_INFO, TEST_USER_DATA + from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -TEST_USER_DATA = { - CONF_IP_ADDRESS: "192.168.0.0", - CONF_PASSWORD: "password", -} -TEST_DEVICE_INFO = DeviceInfo( - id="id", - name="name", - model="model", - sw_version="sw", - hw_version="hw", -) DEFAULT_OPTIONS = { CONF_CLIP_NEGATIVE: True, CONF_RETURN_AVERAGE: True, } -async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: +async def test_form( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, +) -> None: """Test we get the form.""" caplog.set_level(logging.DEBUG) result = await hass.config_entries.flow.async_init( @@ -47,53 +42,49 @@ async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with ( - patch("aioairq.AirQ.validate"), - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_USER_DATA, - ) - await hass.async_block_till_done() - assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_DATA, + ) + await hass.async_block_till_done() + assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] assert result2["data"] == TEST_USER_DATA -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioairq.AirQ.validate", side_effect=InvalidAuth): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} - ) + mock_airq.validate.side_effect = InvalidAuth + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioairq.AirQ.validate", side_effect=ClientConnectionError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA - ) + mock_airq.validate.side_effect = ClientConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_duplicate_error(hass: HomeAssistant) -> None: +async def test_duplicate_error(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test that errors are shown when duplicates are added.""" MockConfigEntry( data=TEST_USER_DATA, @@ -105,13 +96,9 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch("aioairq.AirQ.validate"), - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA - ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/airq/test_coordinator.py b/tests/components/airq/test_coordinator.py index 69f7c9dee17..f45986df61d 100644 --- a/tests/components/airq/test_coordinator.py +++ b/tests/components/airq/test_coordinator.py @@ -1,9 +1,8 @@ """Test the air-Q coordinator.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock -from aioairq import DeviceInfo as AirQDeviceInfo import pytest from homeassistant.components.airq import AirQCoordinator @@ -12,9 +11,10 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo +from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO + from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures("mock_setup_entry") MOCKED_ENTRY = MockConfigEntry( domain=DOMAIN, data={ @@ -24,14 +24,6 @@ MOCKED_ENTRY = MockConfigEntry( unique_id="123-456", ) -TEST_DEVICE_INFO = AirQDeviceInfo( - id="id", - name="name", - model="model", - sw_version="sw", - hw_version="hw", -) -TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} STATUS_WARMUP = { "co": "co sensor still in warm up phase; waiting time = 18 s", "tvoc": "tvoc sensor still in warm up phase; waiting time = 18 s", @@ -40,7 +32,9 @@ STATUS_WARMUP = { async def test_logging_in_coordinator_first_update_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that the first AirQCoordinator._async_update_data call logs necessary setup. @@ -56,11 +50,7 @@ async def test_logging_in_coordinator_first_update_data( assert "name" not in coordinator.device_info # First call: fetch missing device info - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), - ): - await coordinator._async_update_data() + await coordinator._async_update_data() # check that the missing name is logged... assert ( @@ -79,7 +69,9 @@ async def test_logging_in_coordinator_first_update_data( async def test_logging_in_coordinator_subsequent_update_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that the second AirQCoordinator._async_update_data call has nothing to log. @@ -91,11 +83,7 @@ async def test_logging_in_coordinator_subsequent_update_data( coordinator = AirQCoordinator(hass, MOCKED_ENTRY) coordinator.device_info.update(DeviceInfo(**TEST_DEVICE_INFO)) - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), - ): - await coordinator._async_update_data() + await coordinator._async_update_data() # check that the name _is not_ missing assert "name" in coordinator.device_info # and that nothing of the kind is logged @@ -110,19 +98,17 @@ async def test_logging_in_coordinator_subsequent_update_data( async def test_logging_when_warming_up_sensor_present( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that warming up sensors are logged.""" caplog.set_level(logging.DEBUG) coordinator = AirQCoordinator(hass, MOCKED_ENTRY) - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch( - "aioairq.AirQ.get_latest_data", - return_value=TEST_DEVICE_DATA | {"Status": STATUS_WARMUP}, - ), - ): - await coordinator._async_update_data() + mock_airq.get_latest_data.return_value = TEST_DEVICE_DATA | { + "Status": STATUS_WARMUP + } + await coordinator._async_update_data() assert ( f"Following sensors are still warming up: {set(STATUS_WARMUP.keys())}" in caplog.text diff --git a/tests/components/airq/test_number.py b/tests/components/airq/test_number.py new file mode 100644 index 00000000000..b5fa4d65ef1 --- /dev/null +++ b/tests/components/airq/test_number.py @@ -0,0 +1,70 @@ +"""Test the NUMBER platform from air-Q integration.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_platform +from .common import TEST_BRIGHTNESS, TEST_DEVICE_INFO + +ENTITY_ID = f"number.{TEST_DEVICE_INFO['name']}_led_brightness" + + +@pytest.fixture(autouse=True) +async def number_platform(hass: HomeAssistant, mock_airq: AsyncMock) -> None: + """Configure AirQ integration and validate the setup for NUMBER platform.""" + await setup_platform(hass, Platform.NUMBER) + + # Validate the setup + state = hass.states.get(ENTITY_ID) + assert state is not None, ( + f"{ENTITY_ID} not found among {hass.states.async_entity_ids()}" + ) + assert float(state.state) == TEST_BRIGHTNESS + + +@pytest.mark.parametrize("new_brightness", [0, 100, (TEST_BRIGHTNESS + 10) % 100]) +async def test_number_set_value( + hass: HomeAssistant, mock_airq: AsyncMock, new_brightness +) -> None: + """Test that setting value works.""" + # Simulate the device confirming the new brightness on the next poll + mock_airq.get_current_brightness.return_value = new_brightness + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": ENTITY_ID, "value": new_brightness}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify the API methods were called correctly + mock_airq.set_current_brightness.assert_called_once_with(new_brightness) + + # Validate that the update propagated to the state + state = hass.states.get(ENTITY_ID) + assert state is not None, ( + f"{ENTITY_ID} not found among {hass.states.async_entity_ids()}" + ) + assert float(state.state) == new_brightness + + +@pytest.mark.parametrize("new_brightness", [-1, 110]) +async def test_number_set_invalid_value_caught_by_hass( + hass: HomeAssistant, mock_airq: AsyncMock, new_brightness +) -> None: + """Test that setting incorrect values errors.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "number", + "set_value", + {"entity_id": ENTITY_ID, "value": new_brightness}, + blocking=True, + ) + + mock_airq.set_current_brightness.assert_not_called() diff --git a/tests/components/airthings/__init__.py b/tests/components/airthings/__init__.py index e331fb2f2c6..0d2c58c22ae 100644 --- a/tests/components/airthings/__init__.py +++ b/tests/components/airthings/__init__.py @@ -1 +1,12 @@ """Tests for the Airthings integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airthings/conftest.py b/tests/components/airthings/conftest.py new file mode 100644 index 00000000000..4c67e35108c --- /dev/null +++ b/tests/components/airthings/conftest.py @@ -0,0 +1,79 @@ +"""Airthings test configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from airthings import Airthings, AirthingsDevice +import pytest + +from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.const import CONF_ID + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ID: "client_id", + CONF_SECRET: "secret", + }, + unique_id="client_id", + ) + + +@pytest.fixture(params=["view_plus", "wave_plus", "wave_enhance"]) +def airthings_fixture( + request: pytest.FixtureRequest, +) -> str: + """Return the fixture name for Airthings device types.""" + return request.param + + +@pytest.fixture +def mock_airthings_device(airthings_fixture: str) -> AirthingsDevice: + """Mock an Airthings device.""" + return AirthingsDevice( + **load_json_object_fixture(f"device_{airthings_fixture}.json", DOMAIN) + ) + + +@pytest.fixture +def mock_airthings_client( + mock_airthings_device: AirthingsDevice, mock_airthings_token: AsyncMock +) -> Generator[Airthings]: + """Mock an Airthings client.""" + with patch( + "homeassistant.components.airthings.Airthings", + autospec=True, + ) as mock_airthings: + client = mock_airthings.return_value + client.update_devices.return_value = { + mock_airthings_device.device_id: mock_airthings_device + } + yield client + + +@pytest.fixture +def mock_airthings_token() -> Generator[Airthings]: + """Mock an Airthings client.""" + with ( + patch( + "homeassistant.components.airthings.config_flow.airthings.get_token", + return_value="test_token", + ) as mock_get_token, + ): + yield mock_get_token + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airthings/fixtures/device_view_plus.json b/tests/components/airthings/fixtures/device_view_plus.json new file mode 100644 index 00000000000..194b0493d2e --- /dev/null +++ b/tests/components/airthings/fixtures/device_view_plus.json @@ -0,0 +1,19 @@ +{ + "device_id": "2960000001", + "name": "Living Room", + "is_active": true, + "device_type": "VIEW_PLUS", + "product_name": "View Plus", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "pm1": 4.4, + "pm25": 5.5, + "pressure": 6.6, + "radonShortTermAvg": 7.7, + "temp": 8.8, + "voc": 9.9 + } +} diff --git a/tests/components/airthings/fixtures/device_wave_enhance.json b/tests/components/airthings/fixtures/device_wave_enhance.json new file mode 100644 index 00000000000..06c7c489ad1 --- /dev/null +++ b/tests/components/airthings/fixtures/device_wave_enhance.json @@ -0,0 +1,18 @@ +{ + "device_id": "3210000003", + "name": "Bedroom", + "is_active": true, + "device_type": "WAVE_ENHANCE", + "product_name": "Wave Enhance", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "lux": 4.4, + "pressure": 5.5, + "sla": 6.6, + "temp": 7.7, + "voc": 8.8 + } +} diff --git a/tests/components/airthings/fixtures/device_wave_plus.json b/tests/components/airthings/fixtures/device_wave_plus.json new file mode 100644 index 00000000000..0acf09daa62 --- /dev/null +++ b/tests/components/airthings/fixtures/device_wave_plus.json @@ -0,0 +1,17 @@ +{ + "device_id": "2930000002", + "name": "Office", + "is_active": true, + "device_type": "WAVE_PLUS", + "product_name": "Wave Plus", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "pressure": 4.4, + "radonShortTermAvg": 5.5, + "temp": 6.6, + "voc": 7.7 + } +} diff --git a/tests/components/airthings/snapshots/test_sensor.ambr b/tests/components/airthings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9cc3d1bcd13 --- /dev/null +++ b/tests/components/airthings/snapshots/test_sensor.ambr @@ -0,0 +1,1352 @@ +# serializer version: 1 +# name: test_all_device_types[view_plus][sensor.living_room_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Living Room Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Living Room Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.living_room_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Living Room Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pm1', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Living Room PM1', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pm25', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Living Room PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_radon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_radon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon', + 'unique_id': '2960000001_radonShortTermAvg', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_radon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Radon', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_radon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.8', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_volatile_organic_compounds_parts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Living Room Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.living_room_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.9', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Bedroom Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bedroom_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bedroom Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Bedroom Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.bedroom_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Bedroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_lux', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Bedroom Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.bedroom_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_sound_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_sound_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sound pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_sla', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_sound_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': 'Bedroom Sound pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_sound_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_volatile_organic_compounds_parts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Bedroom Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.bedroom_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.8', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Office Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.office_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Office Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Office Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.office_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Office Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_radon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_radon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon', + 'unique_id': '2930000002_radonShortTermAvg', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_radon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Radon', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.office_radon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_volatile_organic_compounds_parts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Office Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.office_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index a96fe33c9d0..f8791df0c26 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -1,12 +1,12 @@ """Test the Airthings config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import airthings import pytest -from homeassistant import config_entries from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -23,123 +23,102 @@ DHCP_SERVICE_INFO = [ DhcpServiceInfo( hostname="airthings-view", ip="192.168.1.100", - macaddress="00:00:00:00:00:00", + macaddress="000000000000", ), DhcpServiceInfo( hostname="airthings-hub", ip="192.168.1.101", - macaddress="D0:14:11:90:00:00", + macaddress="d01411900000", ), DhcpServiceInfo( hostname="airthings-hub", ip="192.168.1.102", - macaddress="70:B3:D5:2A:00:00", + macaddress="70b3d52a0000", ), ] -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_flow( + hass: HomeAssistant, mock_airthings_token: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we get the full flow working.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with ( - patch( - "airthings.get_token", - return_value="test_token", - ), - patch( - "homeassistant.components.airthings.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings" assert result["data"] == TEST_DATA + assert result["result"].unique_id == "client_id" assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("exception", "error"), + [ + (airthings.AirthingsAuthError, "invalid_auth"), + (airthings.AirthingsConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_airthings_token: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle exceptions correctly.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "airthings.get_token", - side_effect=airthings.AirthingsAuthError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) + mock_airthings_token.side_effect = exception - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - 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"], + TEST_DATA, ) - with patch( - "airthings.get_token", - side_effect=airthings.AirthingsConnectionError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} + mock_airthings_token.side_effect = None -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - 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"], + TEST_DATA, ) - with patch( - "airthings.get_token", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_flow_entry_already_exists( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test user input for config_entry that already exists.""" + mock_config_entry.add_to_hass(hass) - first_entry = MockConfigEntry( - domain="airthings", - data=TEST_DATA, - unique_id=TEST_DATA[CONF_ID], + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - first_entry.add_to_hass(hass) - with patch("airthings.get_token", return_value="token"): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA - ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -147,54 +126,45 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: @pytest.mark.parametrize("dhcp_service_info", DHCP_SERVICE_INFO) async def test_dhcp_flow( - hass: HomeAssistant, dhcp_service_info: DhcpServiceInfo + hass: HomeAssistant, + dhcp_service_info: DhcpServiceInfo, + mock_airthings_token: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: """Test the DHCP discovery flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, + context={"source": SOURCE_DHCP}, data=dhcp_service_info, - context={"source": config_entries.SOURCE_DHCP}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.airthings.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "airthings.get_token", - return_value="test_token", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings" assert result["data"] == TEST_DATA + assert result["result"].unique_id == TEST_DATA[CONF_ID] assert len(mock_setup_entry.mock_calls) == 1 -async def test_dhcp_flow_hub_already_configured(hass: HomeAssistant) -> None: +async def test_dhcp_flow_hub_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that DHCP discovery fails when already configured.""" - first_entry = MockConfigEntry( - domain="airthings", - data=TEST_DATA, - unique_id=TEST_DATA[CONF_ID], - ) - first_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, + context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO[0], - context={"source": config_entries.SOURCE_DHCP}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/airthings/test_sensor.py b/tests/components/airthings/test_sensor.py new file mode 100644 index 00000000000..d78d3356244 --- /dev/null +++ b/tests/components/airthings/test_sensor.py @@ -0,0 +1,23 @@ +"""Test the Airthings sensors.""" + +from airthings import Airthings +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_device_types( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_airthings_client: Airthings, + entity_registry: er.EntityRegistry, +) -> None: + """Test all device types.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/airzone_cloud/conftest.py b/tests/components/airzone_cloud/conftest.py index b289efd3fb9..10388eb63d3 100644 --- a/tests/components/airzone_cloud/conftest.py +++ b/tests/components/airzone_cloud/conftest.py @@ -2,20 +2,34 @@ from unittest.mock import patch +from aioairzone_cloud.cloudapi import AirzoneCloudApi import pytest +class MockAirzoneCloudApi(AirzoneCloudApi): + """Mock AirzoneCloudApi class.""" + + async def mock_update(self: "AirzoneCloudApi"): + """Mock AirzoneCloudApi _update function.""" + await self.update_polling() + + @pytest.fixture(autouse=True) def airzone_cloud_no_websockets(): """Fixture to completely disable Airzone Cloud WebSockets.""" with ( patch( - "homeassistant.components.airzone_cloud.AirzoneCloudApi._update_websockets", - return_value=False, + "homeassistant.components.airzone_cloud.AirzoneCloudApi._update", + side_effect=MockAirzoneCloudApi.mock_update, + autospec=True, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.connect_installation_websockets", return_value=None, ), + patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_websockets", + return_value=None, + ), ): yield diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 4bd7bfaccdd..3d566e6297b 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -210,10 +210,35 @@ 'ws-connected': True, }), }), + 'air-quality': dict({ + 'airqsensor1': dict({ + 'aq-active': False, + 'aq-index': 1, + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', + 'available': True, + 'double-set-point': False, + 'id': 'airqsensor1', + 'installation': 'installation1', + 'is-connected': True, + 'name': 'CapteurQ', + 'problems': False, + 'system': 1, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 1, + }), + }), 'groups': dict({ 'group1': dict({ 'action': 1, 'active': True, + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'hot-water': list([ 'dhw1', @@ -332,6 +357,9 @@ 'aidoo1', 'aidoo_pro', ]), + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'groups': list([ 'group1', @@ -377,6 +405,7 @@ }), 'systems': dict({ 'system1': dict({ + 'aq-active': False, 'aq-index': 1, 'aq-pm-1': 3, 'aq-pm-10': 3, @@ -463,6 +492,7 @@ 'action': 1, 'active': True, 'air-demand': True, + 'air-quality-id': 'airqsensor1', 'aq-active': False, 'aq-index': 1, 'aq-mode-conf': 'auto', @@ -528,19 +558,12 @@ 'action': 6, 'active': False, 'air-demand': False, - 'aq-active': False, - 'aq-index': 1, 'aq-mode-conf': 'auto', 'aq-mode-values': list([ 'off', 'on', 'auto', ]), - 'aq-pm-1': 3, - 'aq-pm-10': 3, - 'aq-pm-2.5': 4, - 'aq-present': True, - 'aq-status': 'good', 'available': True, 'double-set-point': False, 'floor-demand': False, diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index bb2d0f78060..d88f66e6b2c 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -45,7 +45,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dormitorio_air_quality_active") - assert state.state == STATE_OFF + assert state is None state = hass.states.get("binary_sensor.dormitorio_battery") assert state.state == STATE_OFF diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index 672e10adedb..330a9efbef1 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -59,19 +59,19 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: # Zones state = hass.states.get("sensor.dormitorio_air_quality_index") - assert state.state == "1" + assert state is None state = hass.states.get("sensor.dormitorio_battery") assert state.state == "54" state = hass.states.get("sensor.dormitorio_pm1") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_pm2_5") - assert state.state == "4" + assert state is None state = hass.states.get("sensor.dormitorio_pm10") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_signal_percentage") assert state.state == "76" @@ -82,7 +82,7 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.dormitorio_humidity") assert state.state == "24" - state = hass.states.get("sensor.dormitorio_air_quality_index") + state = hass.states.get("sensor.salon_air_quality_index") assert state.state == "1" state = hass.states.get("sensor.salon_pm1") diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 52b0ae0bec3..835011f8c8c 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -19,6 +19,7 @@ from aioairzone_cloud.const import ( API_AZ_ACS, API_AZ_AIDOO, API_AZ_AIDOO_PRO, + API_AZ_AIRQSENSOR, API_AZ_SYSTEM, API_AZ_ZONE, API_CELSIUS, @@ -170,6 +171,17 @@ GET_INSTALLATION_MOCK = { }, API_WS_ID: WS_ID, }, + { + API_CONFIG: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 1, + }, + API_DEVICE_ID: "airqsensor1", + API_NAME: "CapteurQ", + API_TYPE: API_AZ_AIRQSENSOR, + API_META: {}, + API_WS_ID: WS_ID, + }, ], }, { @@ -394,11 +406,6 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "system1": return { API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_ERRORS: [ { API_OLD_ID: "error-id", @@ -419,14 +426,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: True, API_AIR_ACTIVE: True, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 30, API_MODE: OperationMode.COOLING.value, @@ -466,14 +467,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: False, API_AIR_ACTIVE: False, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 24, API_MODE: OperationMode.COOLING.value, @@ -504,6 +499,19 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } + if device.get_id() == "airqsensor1": + return { + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + } return {} diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index a7335017691..d52ee5733a1 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -245,7 +245,9 @@ async def test_get_action_capabilities( "arm_night": {"extra_fields": []}, "arm_vacation": {"extra_fields": []}, "disarm": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "trigger": {"extra_fields": []}, } @@ -293,7 +295,9 @@ async def test_get_action_capabilities_legacy( "arm_night": {"extra_fields": []}, "arm_vacation": {"extra_fields": []}, "disarm": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "trigger": {"extra_fields": []}, } @@ -338,19 +342,29 @@ async def test_get_action_capabilities_arm_code( expected_capabilities = { "arm_away": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "arm_home": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "arm_night": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "arm_vacation": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "disarm": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "trigger": {"extra_fields": []}, } @@ -394,19 +408,29 @@ async def test_get_action_capabilities_arm_code_legacy( expected_capabilities = { "arm_away": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "arm_home": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "arm_night": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "arm_vacation": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "disarm": { - "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + "extra_fields": [ + {"name": "code", "optional": True, "required": False, "type": "string"} + ] }, "trigger": {"extra_fields": []}, } diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 3efacb80560..979bc33bb00 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -191,7 +191,12 @@ async def test_get_trigger_capabilities( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } @@ -226,7 +231,12 @@ async def test_get_trigger_capabilities_legacy( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 6998b2acc97..4d8d0dca67f 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components import fan, humidifier, remote, water_heater from homeassistant.components.alexa import smart_home from homeassistant.const import EntityCategory, UnitOfTemperature, __version__ from homeassistant.core import HomeAssistant @@ -200,3 +201,167 @@ async def test_serialize_discovery_recovers( "Error serializing Alexa.PowerController discovery" f" for {hass.states.get('switch.bla')}" ) in caplog.text + + +@pytest.mark.parametrize( + ("domain", "state", "state_attributes", "mode_controller_exists"), + [ + ("switch", "on", {}, False), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": "eco", + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": [], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + False, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto", "manual"], + "mode": "auto", + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto"], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": [], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + False, + ), + ( + "remote", + "on", + { + "activity_list": ["tv", "dvd"], + "current_activity": "tv", + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": ["tv"], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": [], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + False, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on", "auto"], + "operation_mode": "auto", + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on"], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": [], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + False, + ), + ], +) +async def test_mode_controller_is_omitted_if_no_modes_are_set( + hass: HomeAssistant, + domain: str, + state: str, + state_attributes: dict[str, Any], + mode_controller_exists: bool, +) -> None: + """Test we do not generate an invalid discovery with AlexaModeController during serialize discovery. + + AlexModeControllers need at least 2 modes. If one mode is set, an extra mode will be added for compatibility. + If no modes are offered, the mode controller should be omitted to prevent schema validations. + """ + request = get_new_request("Alexa.Discovery", "Discover") + + hass.states.async_set( + f"{domain}.bla", state, {"friendly_name": "Boop Woz"} | state_attributes + ) + + msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) + msg = msg["event"] + + interfaces = { + ifc["interface"] for ifc in msg["payload"]["endpoints"][0]["capabilities"] + } + + assert ("Alexa.ModeController" in interfaces) is mode_controller_exists diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 79851550528..22596706862 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -50,6 +50,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: device_type="echo", device_owner_customer_id="amazon_ower_id", device_cluster_members=[TEST_SERIAL_NUMBER], + device_locale="en-US", online=True, serial_number=TEST_SERIAL_NUMBER, software_version="echo_test_software_version", @@ -68,6 +69,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( device.device_type ) + client.send_sound_notification = AsyncMock() yield client diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 8a2f5b6b158..6a4dff1c38d 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -5,3 +5,5 @@ TEST_COUNTRY = "IT" TEST_PASSWORD = "fake_password" TEST_SERIAL_NUMBER = "echo_test_serial_number" TEST_USERNAME = "fake_email@gmail.com" + +TEST_DEVICE_ID = "echo_test_device_id" diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr index e0460c4c173..bf28f8fb1a1 100644 --- a/tests/components/alexa_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'echo_test_serial_number', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Amazon', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'echo_test_serial_number', - 'suggested_area': None, 'sw_version': 'echo_test_software_version', 'via_device_id': None, }) diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr new file mode 100644 index 00000000000..b95108b0d03 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_send_sound_service + _Call( + tuple( + dict({ + 'account_name': 'Echo Test', + 'appliance_id': 'G1234567890123456789012345678A', + 'bluetooth_state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device_cluster_members': list([ + 'echo_test_serial_number', + ]), + 'device_family': 'mine', + 'device_locale': 'en-US', + 'device_owner_customer_id': 'amazon_ower_id', + 'device_type': 'echo', + 'do_not_disturb': False, + 'entity_id': '11111111-2222-3333-4444-555555555555', + 'online': True, + 'response_style': None, + 'sensors': dict({ + 'temperature': dict({ + 'name': 'temperature', + 'scale': 'CELSIUS', + 'value': '22.5', + }), + }), + 'serial_number': 'echo_test_serial_number', + 'software_version': 'echo_test_software_version', + }), + 'chimes_bells_01', + ), + dict({ + }), + ) +# --- +# name: test_send_text_service + _Call( + tuple( + dict({ + 'account_name': 'Echo Test', + 'appliance_id': 'G1234567890123456789012345678A', + 'bluetooth_state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device_cluster_members': list([ + 'echo_test_serial_number', + ]), + 'device_family': 'mine', + 'device_locale': 'en-US', + 'device_owner_customer_id': 'amazon_ower_id', + 'device_type': 'echo', + 'do_not_disturb': False, + 'entity_id': '11111111-2222-3333-4444-555555555555', + 'online': True, + 'response_style': None, + 'sensors': dict({ + 'temperature': dict({ + 'name': 'temperature', + 'scale': 'CELSIUS', + 'value': '22.5', + }), + }), + 'serial_number': 'echo_test_serial_number', + 'software_version': 'echo_test_software_version', + }), + 'Play B.B.C. radio on TuneIn', + ), + dict({ + }), + ) +# --- diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 9bf174c5955..e1b2974184b 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + WrongCountry, +) import pytest from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN @@ -57,6 +62,8 @@ async def test_full_flow( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (CannotRetrieveData, "cannot_retrieve_data"), + (WrongCountry, "wrong_country"), ], ) async def test_flow_errors( @@ -133,3 +140,78 @@ async def test_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_successful( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + CONF_CODE: "000000", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (CannotRetrieveData, "cannot_retrieve_data"), + ], +) +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test starting a reauthentication flow but no connection found.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_amazon_devices_client.login_mode_interactive.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + CONF_CODE: "000000", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_amazon_devices_client.login_mode_interactive.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + CONF_CODE: "111111", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" + assert mock_config_entry.data[CONF_CODE] == "111111" diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py new file mode 100644 index 00000000000..914664199c2 --- /dev/null +++ b/tests/components/alexa_devices/test_services.py @@ -0,0 +1,195 @@ +"""Tests for Alexa Devices services.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.alexa_devices.services import ( + ATTR_SOUND, + ATTR_SOUND_VARIANT, + ATTR_TEXT_COMMAND, + SERVICE_SOUND_NOTIFICATION, + SERVICE_TEXT_COMMAND, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import TEST_DEVICE_ID, TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry, mock_device_registry + + +async def test_setup_services( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup of Alexa Devices services.""" + await setup_integration(hass, mock_config_entry) + + assert (services := hass.services.async_services_for_domain(DOMAIN)) + assert SERVICE_TEXT_COMMAND in services + assert SERVICE_SOUND_NOTIFICATION in services + + +async def test_send_sound_service( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test send sound service.""" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device_entry + + await hass.services.async_call( + DOMAIN, + SERVICE_SOUND_NOTIFICATION, + { + ATTR_SOUND: "chimes_bells", + ATTR_SOUND_VARIANT: 1, + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert mock_amazon_devices_client.call_alexa_sound.call_count == 1 + assert mock_amazon_devices_client.call_alexa_sound.call_args == snapshot + + +async def test_send_text_service( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test send text service.""" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device_entry + + await hass.services.async_call( + DOMAIN, + SERVICE_TEXT_COMMAND, + { + ATTR_TEXT_COMMAND: "Play B.B.C. radio on TuneIn", + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert mock_amazon_devices_client.call_alexa_text_command.call_count == 1 + assert mock_amazon_devices_client.call_alexa_text_command.call_args == snapshot + + +@pytest.mark.parametrize( + ("sound", "device_id", "translation_key", "translation_placeholders"), + [ + ( + "chimes_bells", + "fake_device_id", + "invalid_device_id", + {"device_id": "fake_device_id"}, + ), + ( + "wrong_sound_name", + TEST_DEVICE_ID, + "invalid_sound_value", + { + "sound": "wrong_sound_name", + "variant": "1", + }, + ), + ], +) +async def test_invalid_parameters( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + sound: str, + device_id: str, + translation_key: str, + translation_placeholders: dict[str, str], +) -> None: + """Test invalid service parameters.""" + + device_entry = dr.DeviceEntry( + id=TEST_DEVICE_ID, identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + mock_device_registry( + hass, + {device_entry.id: device_entry}, + ) + await setup_integration(hass, mock_config_entry) + + # Call Service + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_SOUND_NOTIFICATION, + { + ATTR_SOUND: sound, + ATTR_SOUND_VARIANT: 1, + ATTR_DEVICE_ID: device_id, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == translation_placeholders + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry not loaded.""" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device_entry + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + # Call Service + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_SOUND_NOTIFICATION, + { + ATTR_SOUND: "chimes_bells", + ATTR_SOUND_VARIANT: 1, + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "entry_not_loaded" + assert exc_info.value.translation_placeholders == {"entry": mock_config_entry.title} diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py new file mode 100644 index 00000000000..1cf190bd297 --- /dev/null +++ b/tests/components/alexa_devices/test_utils.py @@ -0,0 +1,56 @@ +"""Tests for Alexa Devices utils.""" + +from unittest.mock import AsyncMock + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +import pytest + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +ENTITY_ID = "switch.echo_test_do_not_disturb" + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_connect_with_error", "CannotConnect()"), + (CannotRetrieveData, "cannot_retrieve_data_with_error", "CannotRetrieveData()"), + ], +) +async def test_alexa_api_call_exceptions( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test alexa_api_call decorator for exceptions.""" + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + mock_amazon_devices_client.set_do_not_disturb.side_effect = side_effect + + # Call API + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} diff --git a/tests/components/altruist/snapshots/test_sensor.ambr b/tests/components/altruist/snapshots/test_sensor.ambr index ca74e75542f..9340e10cbe8 100644 --- a/tests/components/altruist/snapshots/test_sensor.ambr +++ b/tests/components/altruist/snapshots/test_sensor.ambr @@ -319,7 +319,7 @@ 'supported_features': 0, 'translation_key': 'pm_10', 'unique_id': '5366960e8b18-SDS_P1', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[sensor.5366960e8b18_pm10-state] @@ -328,7 +328,7 @@ 'device_class': 'pm10', 'friendly_name': '5366960e8b18 PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.5366960e8b18_pm10', @@ -375,7 +375,7 @@ 'supported_features': 0, 'translation_key': 'pm_25', 'unique_id': '5366960e8b18-SDS_P2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[sensor.5366960e8b18_pm2_5-state] @@ -384,7 +384,7 @@ 'device_class': 'pm25', 'friendly_name': '5366960e8b18 PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.5366960e8b18_pm2_5', diff --git a/tests/components/amberelectric/__init__.py b/tests/components/amberelectric/__init__.py index 9eae18c65aa..8ee603cee14 100644 --- a/tests/components/amberelectric/__init__.py +++ b/tests/components/amberelectric/__init__.py @@ -1 +1,13 @@ """Tests for the amberelectric integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py index ce4073db71b..57f93074883 100644 --- a/tests/components/amberelectric/conftest.py +++ b/tests/components/amberelectric/conftest.py @@ -1,10 +1,59 @@ """Provide common Amber fixtures.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, Mock, patch +from amberelectric.models.interval import Interval import pytest +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_API_TOKEN + +from .helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + FORECASTS, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_CHANNEL_WITH_RANGE, + GENERAL_FORECASTS, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + +MOCK_API_TOKEN = "psk_0000000000000000" + + +def create_amber_config_entry( + site_id: str, entry_id: str, name: str +) -> MockConfigEntry: + """Create an Amber config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_NAME: name, + CONF_SITE_ID: site_id, + }, + entry_id=entry_id, + ) + + +@pytest.fixture +def mock_amber_client() -> Generator[AsyncMock]: + """Mock the Amber API client.""" + with patch( + "homeassistant.components.amberelectric.amberelectric.AmberApi", + autospec=True, + ) as mock_client: + yield mock_client + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +62,129 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.amberelectric.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def general_channel_config_entry(): + """Generate the default Amber config entry.""" + return create_amber_config_entry(GENERAL_ONLY_SITE_ID, GENERAL_ONLY_SITE_ID, "home") + + +@pytest.fixture +async def general_channel_and_controlled_load_config_entry(): + """Generate the default Amber config entry for site with controlled load.""" + return create_amber_config_entry( + GENERAL_AND_CONTROLLED_SITE_ID, GENERAL_AND_CONTROLLED_SITE_ID, "home" + ) + + +@pytest.fixture +async def general_channel_and_feed_in_config_entry(): + """Generate the default Amber config entry for site with feed in.""" + return create_amber_config_entry( + GENERAL_AND_FEED_IN_SITE_ID, GENERAL_AND_FEED_IN_SITE_ID, "home" + ) + + +@pytest.fixture +def general_channel_prices() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL + + +@pytest.fixture +def general_channel_prices_with_range() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL_WITH_RANGE + + +@pytest.fixture +def controlled_load_channel_prices() -> list[Interval]: + """List containing controlled load channel prices.""" + return CONTROLLED_LOAD_CHANNEL + + +@pytest.fixture +def feed_in_channel_prices() -> list[Interval]: + """List containing feed in channel prices.""" + return FEED_IN_CHANNEL + + +@pytest.fixture +def forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return FORECASTS + + +@pytest.fixture +def general_forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return GENERAL_FORECASTS + + +@pytest.fixture +def mock_amber_client_general_channel( + mock_amber_client: AsyncMock, general_channel_prices: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_channel_with_range( + mock_amber_client: AsyncMock, general_channel_prices_with_range: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices with a range.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices_with_range + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_and_controlled_load( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + controlled_load_channel_prices: list[Interval], +) -> Generator[AsyncMock]: + """Fake general channel and controlled load channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + controlled_load_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_and_feed_in( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + feed_in_channel_prices: list[Interval], +) -> AsyncGenerator[Mock]: + """Set up general channel and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + feed_in_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_forecasts( + mock_amber_client: AsyncMock, forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel, controlled load and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = forecast_prices + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_forecasts( + mock_amber_client: AsyncMock, general_forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel only.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_forecast_prices + return mock_amber_client diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py index 971f3690a0d..d4f968f01d1 100644 --- a/tests/components/amberelectric/helpers.py +++ b/tests/components/amberelectric/helpers.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta from amberelectric.models.actual_interval import ActualInterval +from amberelectric.models.advanced_price import AdvancedPrice from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval from amberelectric.models.interval import Interval from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.models.range import Range from amberelectric.models.spike_status import SpikeStatus from dateutil import parser @@ -15,12 +17,16 @@ from dateutil import parser def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval: """Generate a mock actual interval.""" start_time = end_time - timedelta(minutes=30) + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 return Interval( ActualInterval( type="ActualInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -34,16 +40,23 @@ def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> I def generate_current_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, + end_time: datetime, + range=False, ) -> Interval: """Generate a mock current price.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( CurrentInterval( type="CurrentInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -56,18 +69,28 @@ def generate_current_interval( ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + + return interval + def generate_forecast_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, end_time: datetime, range=False, advanced_price=False ) -> Interval: """Generate a mock forecast interval.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( ForecastInterval( type="ForecastInterval", duration=30, spot_per_kwh=1.1, - per_kwh=8.8, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -79,12 +102,20 @@ def generate_forecast_interval( estimate=True, ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + if advanced_price: + interval.actual_instance.advanced_price = AdvancedPrice( + low=6.7, predicted=9.0, high=10.2 + ) + return interval GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ" GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162" GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S" GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S" +GENERAL_FOR_FAIL = "01JVCEYVSD5HGJG0KT7RNM91GG" GENERAL_CHANNEL = [ generate_current_interval( @@ -101,6 +132,21 @@ GENERAL_CHANNEL = [ ), ] +GENERAL_CHANNEL_WITH_RANGE = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00"), range=True + ), +] + CONTROLLED_LOAD_CHANNEL = [ generate_current_interval( ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") @@ -131,3 +177,93 @@ FEED_IN_CHANNEL = [ ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00") ), ] + +GENERAL_FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] + +FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 6faabc924b4..b4557fb2a4d 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -9,18 +9,18 @@ from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.models.channel import Channel, ChannelType from amberelectric.models.interval import Interval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.models.site import Site from amberelectric.models.site_status import SiteStatus from amberelectric.models.spike_status import SpikeStatus from dateutil import parser import pytest -from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME -from homeassistant.components.amberelectric.coordinator import ( - AmberUpdateCoordinator, - normalize_descriptor, +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + REQUEST_TIMEOUT, ) +from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -98,18 +98,6 @@ def mock_api_current_price() -> Generator: yield instance -def test_normalize_descriptor() -> None: - """Test normalizing descriptors works correctly.""" - assert normalize_descriptor(None) is None - assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" - assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" - assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" - assert normalize_descriptor(PriceDescriptor.LOW) == "low" - assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" - assert normalize_descriptor(PriceDescriptor.HIGH) == "high" - assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" - - async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: """Test fetching a site with only a general channel.""" @@ -120,7 +108,9 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, + next=288, + _request_timeout=REQUEST_TIMEOUT, ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -152,7 +142,9 @@ async def test_fetch_no_general_site( await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, + next=288, + _request_timeout=REQUEST_TIMEOUT, ) @@ -166,7 +158,9 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, + next=288, + _request_timeout=REQUEST_TIMEOUT, ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -217,7 +211,9 @@ async def test_fetch_general_and_controlled_load_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_CONTROLLED_SITE_ID, next=48 + GENERAL_AND_CONTROLLED_SITE_ID, + next=288, + _request_timeout=REQUEST_TIMEOUT, ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -257,7 +253,9 @@ async def test_fetch_general_and_feed_in_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_FEED_IN_SITE_ID, next=48 + GENERAL_AND_FEED_IN_SITE_ID, + next=288, + _request_timeout=REQUEST_TIMEOUT, ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance diff --git a/tests/components/amberelectric/test_helpers.py b/tests/components/amberelectric/test_helpers.py new file mode 100644 index 00000000000..958c60fd1b3 --- /dev/null +++ b/tests/components/amberelectric/test_helpers.py @@ -0,0 +1,17 @@ +"""Test formatters.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +from homeassistant.components.amberelectric.helpers import normalize_descriptor + + +def test_normalize_descriptor() -> None: + """Test normalizing descriptors works correctly.""" + assert normalize_descriptor(None) is None + assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" + assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" + assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" + assert normalize_descriptor(PriceDescriptor.LOW) == "low" + assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" + assert normalize_descriptor(PriceDescriptor.HIGH) == "high" + assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 203b65d6df6..0d979a2021c 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -1,119 +1,26 @@ """Test the Amber Electric Sensors.""" -from collections.abc import AsyncGenerator -from unittest.mock import Mock, patch - -from amberelectric.models.current_interval import CurrentInterval -from amberelectric.models.interval import Interval -from amberelectric.models.range import Range import pytest -from homeassistant.components.amberelectric.const import ( - CONF_SITE_ID, - CONF_SITE_NAME, - DOMAIN, -) -from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .helpers import ( - CONTROLLED_LOAD_CHANNEL, - FEED_IN_CHANNEL, - GENERAL_AND_CONTROLLED_SITE_ID, - GENERAL_AND_FEED_IN_SITE_ID, - GENERAL_CHANNEL, - GENERAL_ONLY_SITE_ID, -) - -from tests.common import MockConfigEntry - -MOCK_API_TOKEN = "psk_0000000000000000" +from . import MockConfigEntry, setup_integration -@pytest.fixture -async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_SITE_NAME: "mock_title", - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_ONLY_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_controlled_load( - hass: HomeAssistant, -) -> AsyncGenerator[Mock]: - """Set up general channel and controller load channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel and feed in channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price - assert price.state == "0.08" + assert price.state == "0.09" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.09 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -126,32 +33,36 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_min") is None assert attributes.get("range_max") is None - with_range: list[CurrentInterval] = GENERAL_CHANNEL - with_range[0].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_price_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Price sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price attributes = price.attributes - assert attributes.get("range_min") == 0.08 - assert attributes.get("range_max") == 0.12 + assert attributes.get("range_min") == 0.07 + assert attributes.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Price sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price") assert price - assert price.state == "0.08" + assert price.state == "0.04" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.04 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -163,17 +74,20 @@ async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> assert attributes["attribution"] == "Data provided by Amber Electric" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price") assert price - assert price.state == "-0.08" + assert price.state == "-0.01" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == -0.08 + assert attributes["per_kwh"] == -0.01 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -185,10 +99,12 @@ async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: assert attributes["attribution"] == "Data provided by Amber Electric" +@pytest.mark.usefixtures("mock_amber_client_general_channel") async def test_general_forecast_sensor( - hass: HomeAssistant, setup_general: Mock + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry ) -> None: """Test the General Forecast sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price @@ -212,29 +128,33 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_min") is None assert first_forecast.get("range_max") is None - with_range: list[Interval] = GENERAL_CHANNEL - with_range[1].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_forecast_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Forecast sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price attributes = price.attributes first_forecast = attributes["forecasts"][0] - assert first_forecast.get("range_min") == 0.08 - assert first_forecast.get("range_max") == 0.12 + assert first_forecast.get("range_min") == 0.07 + assert first_forecast.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_controlled_load_forecast_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Load Forecast sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_forecast") assert price - assert price.state == "0.09" + assert price.state == "0.04" attributes = price.attributes assert attributes["channel_type"] == "controlledLoad" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -242,7 +162,7 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == 0.09 + assert first_forecast["per_kwh"] == 0.04 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -252,13 +172,16 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_feed_in_forecast_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Forecast sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_forecast") assert price - assert price.state == "-0.09" + assert price.state == "-0.01" attributes = price.attributes assert attributes["channel_type"] == "feedIn" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -266,7 +189,7 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == -0.09 + assert first_forecast["per_kwh"] == -0.01 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -276,38 +199,52 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general") -def test_renewable_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_renewable_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Testing the creation of the Amber renewables sensor.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 sensor = hass.states.get("sensor.mock_title_renewables") assert sensor assert sensor.state == "51" -@pytest.mark.usefixtures("setup_general") -def test_general_price_descriptor_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_descriptor_descriptor_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price Descriptor sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_controlled_load") -def test_general_and_controlled_load_price_descriptor_sensor( +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_descriptor_sensor( hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, ) -> None: """Test the Controlled Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -def test_general_and_feed_in_price_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_descriptor_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price_descriptor") assert price diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py new file mode 100644 index 00000000000..bfff432b18c --- /dev/null +++ b/tests/components/amberelectric/test_services.py @@ -0,0 +1,200 @@ +"""Test the Amber Service object.""" + +import re + +import pytest +import voluptuous as vol + +from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS +from homeassistant.components.amberelectric.services import ATTR_CHANNEL_TYPE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_integration +from .helpers import ( + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_general_forecasts( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.09 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_controlled_load_forecasts( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_CONTROLLED_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.04 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_feed_in_forecasts( + hass: HomeAssistant, + general_channel_and_feed_in_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_FEED_IN_SITE_ID, + ATTR_CHANNEL_TYPE: "feed_in", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == -0.01 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_incorrect_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is incorrect.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + vol.error.MultipleInvalid, + match=re.escape( + "value must be one of ['controlled_load', 'feed_in', 'general'] for dictionary value @ data['channel_type']" + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "incorrect", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_general_forecasts") +async def test_unavailable_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is not found.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + ServiceValidationError, match="There is no controlled_load channel at this site" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_service_entry_availability( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + general_channel_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(general_channel_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id, + ATTR_CHANNEL_TYPE: "general", + }, + blocking=True, + return_response=True, + ) + + with pytest.raises( + ServiceValidationError, + match='Config entry "bad-config_id" not found in registry', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id", ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 01d08572197..1ade8eed37e 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1,8 +1,9 @@ """The tests for the analytics .""" from collections.abc import Generator +from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, patch import aiohttp from awesomeversion import AwesomeVersion @@ -10,7 +11,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type -from homeassistant.components.analytics.analytics import Analytics +from homeassistant.components.analytics.analytics import ( + Analytics, + async_devices_payload, +) from homeassistant.components.analytics.const import ( ANALYTICS_ENDPOINT_URL, ANALYTICS_ENDPOINT_URL_DEV, @@ -22,11 +26,13 @@ from homeassistant.components.analytics.const import ( from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator MOCK_UUID = "abcdefg" MOCK_VERSION = "1970.1.0" @@ -37,8 +43,9 @@ MOCK_VERSION_NIGHTLY = "1970.1.0.dev19700101" @pytest.fixture(autouse=True) def uuid_mock() -> Generator[None]: """Mock the UUID.""" - with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: - hex_mock.return_value = MOCK_UUID + with patch( + "homeassistant.components.analytics.analytics.gen_uuid", return_value=MOCK_UUID + ): yield @@ -966,3 +973,162 @@ async def test_submitting_legacy_integrations( assert submitted_data["integrations"] == ["legacy_binary_sensor"] assert submitted_data == logged_data assert snapshot == submitted_data + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_devices_payload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test devices payload.""" + assert await async_setup_component(hass, "analytics", {}) + assert await async_devices_payload(hass) == { + "version": "home-assistant:1", + "devices": [], + } + + mock_config_entry = MockConfigEntry(domain="hue") + mock_config_entry.add_to_hass(hass) + + mock_custom_config_entry = MockConfigEntry(domain="test") + mock_custom_config_entry.add_to_hass(hass) + + # Normal device with all fields + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "1")}, + sw_version="test-sw-version", + hw_version="test-hw-version", + name="test-name", + manufacturer="test-manufacturer", + model="test-model", + model_id="test-model-id", + suggested_area="Game Room", + configuration_url="http://example.com/config", + ) + + # Service type device + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "2")}, + manufacturer="test-manufacturer", + model_id="test-model-id", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + # Device without model_id + no_model_id_config_entry = MockConfigEntry(domain="no_model_id") + no_model_id_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=no_model_id_config_entry.entry_id, + identifiers={("device", "4")}, + manufacturer="test-manufacturer", + ) + + # Device without manufacturer + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "5")}, + model_id="test-model-id", + ) + + # Device with via_device reference + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "6")}, + manufacturer="test-manufacturer6", + model_id="test-model-id6", + via_device=("device", "1"), + ) + + # Device from custom integration + device_registry.async_get_or_create( + config_entry_id=mock_custom_config_entry.entry_id, + identifiers={("device", "7")}, + manufacturer="test-manufacturer7", + model_id="test-model-id7", + ) + + assert await async_devices_payload(hass) == { + "version": "home-assistant:1", + "devices": [ + { + "manufacturer": "test-manufacturer", + "model_id": "test-model-id", + "model": "test-model", + "sw_version": "test-sw-version", + "hw_version": "test-hw-version", + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": True, + "via_device": None, + "entry_type": None, + }, + { + "manufacturer": "test-manufacturer", + "model_id": "test-model-id", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": False, + "via_device": None, + "entry_type": "service", + }, + { + "manufacturer": "test-manufacturer", + "model_id": None, + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "no_model_id", + "has_configuration_url": False, + "via_device": None, + "entry_type": None, + }, + { + "manufacturer": None, + "model_id": "test-model-id", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": False, + "via_device": None, + "entry_type": None, + }, + { + "manufacturer": "test-manufacturer6", + "model_id": "test-model-id6", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_configuration_url": False, + "via_device": 0, + "entry_type": None, + }, + { + "entry_type": None, + "has_configuration_url": False, + "hw_version": None, + "integration": "test", + "manufacturer": "test-manufacturer7", + "model": None, + "model_id": "test-model-id7", + "sw_version": None, + "via_device": None, + "is_custom_integration": True, + "custom_integration_version": "1.2.3", + }, + ], + } + + client = await hass_client() + response = await client.get("/api/analytics/devices") + assert response.status == HTTPStatus.OK + assert await response.json() == await async_devices_payload(hass) diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 5a8d88dd9f6..efc05772a9a 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -54,9 +54,9 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_IDLE, STATE_OFF, STATE_PLAYING, - STATE_STANDBY, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -163,7 +163,7 @@ async def test_reconnect( state = hass.states.get(entity_id) assert state is not None - assert state.state == STATE_STANDBY + assert state.state == STATE_IDLE assert MSG_RECONNECT[patch_key] in caplog.record_tuples[2] @@ -672,7 +672,7 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: await async_update_entity(hass, entity_id) state = hass.states.get(entity_id) assert state is not None - assert state.state == STATE_STANDBY + assert state.state == STATE_IDLE async def test_download(hass: HomeAssistant) -> None: diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 0968ea5acff..9652ac0c3a9 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1069,3 +1069,100 @@ async def test_options_flow( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert mock_config_entry.options == {CONF_ENABLE_IME: True} + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full reconfigure flow from start to finish without any exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + assert "host" in result["data_schema"].schema + # Form should have as default value the existing host + host_key = next(k for k in result["data_schema"].schema if k.schema == "host") + assert host_key.default() == mock_config_entry.data["host"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock( + return_value=(mock_config_entry.data["name"], mock_config_entry.data["mac"]) + ) + + # Simulate user input with a new host + new_host = "4.3.2.1" + assert new_host != mock_config_entry.data["host"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data["host"] == new_host + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with CannotConnect exception.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect()) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with a different device (unique_id mismatch).""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + # The new host corresponds to a device with a different MAC/unique_id + new_mac = "FF:EE:DD:CC:BB:AA" + assert new_mac != mock_config_entry.data["mac"] + mock_api.async_get_name_and_mac = AsyncMock(return_value=("name", new_mac)) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 09618b135db..8f7a3c43f5e 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -12,15 +12,35 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), dict({ 'agent_id': 'conversation.claude_conversation', - 'content': 'Certainly, calling it now!', + 'content': None, + 'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'), 'role': 'assistant', + 'thinking_content': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Certainly, calling it now!', + 'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'), + 'role': 'assistant', + 'thinking_content': "Okay, let's give it a shot. Will I pass the test?", 'tool_calls': list([ dict({ + 'external': False, 'id': 'toolu_0123456789AbCdEfGhIjKlM', 'tool_args': dict({ 'param1': 'test_value', @@ -39,7 +59,9 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'content': 'I have successfully called the function', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -316,6 +338,39 @@ }), ]) # --- +# name: test_redacted_thinking + list([ + dict({ + 'attachments': None, + 'content': 'ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A98432ECCCE4C1253D5E2D82641AC0E52CC2876CB', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'How can I help you today?', + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- # name: test_unknown_hass_api dict({ 'continue_conversation': False, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3ae44e552cc..f8cccd786fc 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -316,7 +316,7 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") @pytest.mark.parametrize( ("tool_call_json_parts", "expected_call_tool_args"), [ @@ -430,7 +430,7 @@ async def test_function_call( ) -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -728,6 +728,7 @@ async def test_redacted_thinking( hass: HomeAssistant, mock_config_entry_with_extended_thinking: MockConfigEntry, mock_init_component, + snapshot: SnapshotAssertion, ) -> None: """Test extended thinking with redacted thinking blocks.""" with patch( @@ -756,11 +757,11 @@ async def test_redacted_thinking( chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( result.conversation_id ) - assert len(chat_log.content) == 3 - assert chat_log.content[2].content == "How can I help you today?" + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") async def test_extended_thinking_tool_call( mock_get_tools, hass: HomeAssistant, diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 6295bac67cb..ff54539bb39 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,5 +1,6 @@ """Tests for the Anthropic integration.""" +from typing import Any from unittest.mock import patch from anthropic import ( @@ -12,8 +13,12 @@ from httpx import URL, Request, Response import pytest from homeassistant.components.anthropic.const import DOMAIN +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -113,6 +118,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -141,6 +147,211 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="claude", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Claude conversation" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -220,6 +431,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -231,6 +443,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_keys( @@ -311,6 +525,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -329,3 +544,354 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Claude", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Claude 2", + unique_id=None, + ), + ], + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Claude", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="claude", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Claude 2", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Claude" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.claude") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.claude_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_to_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration to version 2.3.""" + # Create a v2.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=2, + subentries_data=[ + { + "data": { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + }, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Claude haiku", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="claude", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index e647b7fa6a5..c4c1b0b1b93 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -17,7 +17,6 @@ 'junctionId', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'A. O. Smith', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'serial', - 'suggested_area': 'Basement', 'sw_version': '2.14', 'via_device_id': None, }) diff --git a/tests/components/apcupsd/conftest.py b/tests/components/apcupsd/conftest.py new file mode 100644 index 00000000000..533694fdb1f --- /dev/null +++ b/tests/components/apcupsd/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the APC UPS Daemon (APCUPSD) tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.apcupsd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr index 39f28b528fc..414c3e451fd 100644 --- a/tests/components/apcupsd/snapshots/test_init.ambr +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'XXXXXXXXXXXX', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.14.14 (31 May 2016) unknown', 'via_device_id': None, }) @@ -50,7 +48,6 @@ 'XXXX', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -83,7 +79,6 @@ 'mocked-config-entry-id', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -116,7 +110,6 @@ 'mocked-config-entry-id', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', @@ -126,7 +119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index e635b7d6681..0a61d8c0ddb 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -2,8 +2,8 @@ from __future__ import annotations -from copy import copy -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock, patch import pytest @@ -18,19 +18,18 @@ from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS from tests.common import MockConfigEntry -def _patch_setup(): - return patch( - "homeassistant.components.apcupsd.async_setup_entry", - return_value=True, - ) - - -async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: - """Test config flow setup with connection error.""" +@pytest.mark.parametrize( + "exception", + [OSError(), asyncio.IncompleteReadError(partial=b"", expected=100), TimeoutError()], +) +async def test_config_flow_cannot_connect( + hass: HomeAssistant, exception: Exception +) -> None: + """Test config flow setup with a connection error.""" with patch( "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" - ) as mock_get: - mock_get.side_effect = OSError() + ) as mock_request_status: + mock_request_status.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, @@ -41,9 +40,11 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "cannot_connect" -async def test_config_flow_duplicate(hass: HomeAssistant) -> None: - """Test duplicate config flow setup.""" - # First add an exiting config entry to hass. +async def test_config_flow_duplicate_host_port( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test duplicate config flow setup with the same host / port.""" + # First add an existing config entry to hass. mock_entry = MockConfigEntry( version=1, domain=DOMAIN, @@ -54,44 +55,22 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: ) mock_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" - ) as mock_request_status, - _patch_setup(), - ): + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status: + # Assign the same host and port, which we should reject since the entry already exists. mock_request_status.return_value = MOCK_STATUS - - # Now, create the integration again using the same config data, we should reject - # the creation due same host / port. result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - # Then, we create the integration once again using a different port. However, - # the apcaccess patch is kept to report the same serial number, we should - # reject the creation as well. - another_host = { - CONF_HOST: CONF_DATA[CONF_HOST], - CONF_PORT: CONF_DATA[CONF_PORT] + 1, + # Now we change the host with a different serial number and add it again. This should be successful. + another_host = CONF_DATA | {CONF_HOST: "another_host"} + mock_request_status.return_value = MOCK_STATUS | { + "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ" } - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=another_host, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - # Now we change the serial number and add it again. This should be successful. - another_device_status = copy(MOCK_STATUS) - another_device_status["SERIALNO"] = MOCK_STATUS["SERIALNO"] + "ZZZ" - mock_request_status.return_value = another_device_status - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -101,14 +80,52 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: assert result["data"] == another_host -async def test_flow_works(hass: HomeAssistant) -> None: +async def test_config_flow_duplicate_serial_number( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test duplicate config flow setup with different host but the same serial number.""" + # First add an existing config entry to hass. + mock_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="APCUPSd", + data=CONF_DATA, + unique_id=MOCK_STATUS["SERIALNO"], + source=SOURCE_USER, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status: + # Assign the different host and port, but we should still reject the creation since the + # serial number is the same as the existing entry. + mock_request_status.return_value = MOCK_STATUS + another_host = CONF_DATA | {CONF_HOST: "another_host"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=another_host, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Now we change the serial number and add it again. This should be successful. + mock_request_status.return_value = MOCK_STATUS | { + "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ" + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=another_host + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == another_host + + +async def test_flow_works(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test successful creation of config entries via user configuration.""" - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", - return_value=MOCK_STATUS, - ), - _patch_setup() as mock_setup, + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -123,8 +140,9 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_STATUS["UPSNAME"] assert result["data"] == CONF_DATA + assert result["result"].unique_id == MOCK_STATUS["SERIALNO"] - mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() @pytest.mark.parametrize( @@ -139,19 +157,19 @@ async def test_flow_works(hass: HomeAssistant) -> None: ], ) async def test_flow_minimal_status( - hass: HomeAssistant, extra_status: dict[str, str], expected_title: str + hass: HomeAssistant, + extra_status: dict[str, str], + expected_title: str, + mock_setup_entry: AsyncMock, ) -> None: """Test successful creation of config entries via user configuration when minimal status is reported. We test different combinations of minimal statuses, where the title of the integration will vary. """ - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" - ) as mock_request_status, - _patch_setup() as mock_setup, - ): + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status: status = MOCK_MINIMAL_STATUS | extra_status mock_request_status.return_value = status @@ -162,10 +180,12 @@ async def test_flow_minimal_status( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == CONF_DATA assert result["title"] == expected_title - mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() -async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: +async def test_reconfigure_flow_works( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test successful reconfiguration of an existing entry.""" mock_entry = MockConfigEntry( version=1, @@ -184,18 +204,15 @@ async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: # New configuration data with different host/port. new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321} - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", - return_value=MOCK_STATUS, - ), - _patch_setup() as mock_setup, + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=new_conf_data ) await hass.async_block_till_done() - mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -205,8 +222,10 @@ async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: assert mock_entry.data[CONF_PORT] == new_conf_data[CONF_PORT] -async def test_reconfigure_flow_cannot_connect(hass: HomeAssistant) -> None: - """Test reconfiguration with connection error.""" +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test reconfiguration with connection error and recovery.""" mock_entry = MockConfigEntry( version=1, domain=DOMAIN, @@ -234,6 +253,19 @@ async def test_reconfigure_flow_cannot_connect(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"]["base"] == "cannot_connect" + # Test recovery by fixing the connection issue. + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=new_conf_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_entry.data == new_conf_data + @pytest.mark.parametrize( ("unique_id_before", "unique_id_after"), diff --git a/tests/components/api/snapshots/test_init.ambr b/tests/components/api/snapshots/test_init.ambr new file mode 100644 index 00000000000..05b6bf31638 --- /dev/null +++ b/tests/components/api/snapshots/test_init.ambr @@ -0,0 +1,144 @@ +# serializer version: 1 +# name: test_api_get_services + list([ + dict({ + 'domain': 'group', + 'services': dict({ + 'reload': dict({ + 'description': 'Reloads group configuration, entities, and notify services from YAML-configuration.', + 'fields': dict({ + }), + 'name': 'Reload', + }), + 'remove': dict({ + 'description': 'Removes a group.', + 'fields': dict({ + 'object_id': dict({ + 'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].', + 'example': 'test_group', + 'name': 'Object ID', + 'required': True, + 'selector': dict({ + 'object': dict({ + }), + }), + }), + }), + 'name': 'Remove', + }), + 'set': dict({ + 'description': 'Creates/Updates a group.', + 'fields': dict({ + 'add_entities': dict({ + 'description': 'List of members to be added to the group. Cannot be used in combination with `Entities` or `Remove entities`.', + 'example': 'domain.entity_id1, domain.entity_id2', + 'name': 'Add entities', + 'selector': dict({ + 'entity': dict({ + 'multiple': True, + 'reorder': False, + }), + }), + }), + 'all': dict({ + 'description': 'Enable this option if the group should only be used when all entities are in state `on`.', + 'name': 'All', + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'entities': dict({ + 'description': 'List of all members in the group. Cannot be used in combination with `Add entities` or `Remove entities`.', + 'example': 'domain.entity_id1, domain.entity_id2', + 'name': 'Entities', + 'selector': dict({ + 'entity': dict({ + 'multiple': True, + 'reorder': False, + }), + }), + }), + 'icon': dict({ + 'description': 'Name of the icon for the group.', + 'example': 'mdi:camera', + 'name': 'Icon', + 'selector': dict({ + 'icon': dict({ + }), + }), + }), + 'name': dict({ + 'description': 'Name of the group.', + 'example': 'My test group', + 'name': 'Name', + 'selector': dict({ + 'text': dict({ + }), + }), + }), + 'object_id': dict({ + 'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].', + 'example': 'test_group', + 'name': 'Object ID', + 'required': True, + 'selector': dict({ + 'text': dict({ + }), + }), + }), + 'remove_entities': dict({ + 'description': 'List of members to be removed from a group. Cannot be used in combination with `Entities` or `Add entities`.', + 'example': 'domain.entity_id1, domain.entity_id2', + 'name': 'Remove entities', + 'selector': dict({ + 'entity': dict({ + 'multiple': True, + 'reorder': False, + }), + }), + }), + }), + 'name': 'Set', + }), + }), + }), + ]) +# --- +# name: test_api_get_services.1 + dict({ + 'domain': 'logger', + 'services': dict({ + 'set_default_level': dict({ + 'description': 'Translated description', + 'fields': dict({ + 'level': dict({ + 'description': 'Field description', + 'example': 'Field example', + 'name': 'Field name', + 'selector': dict({ + 'select': dict({ + 'options': list([ + 'debug', + 'info', + 'warning', + 'error', + 'fatal', + 'critical', + ]), + 'translation_key': 'level', + }), + }), + }), + }), + 'name': 'Translated name', + }), + 'set_level': dict({ + 'description': '', + 'fields': dict({ + }), + 'name': '', + }), + }), + }) +# --- diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 26a3d7c7a8c..382b88b89ea 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -4,18 +4,24 @@ import asyncio from http import HTTPStatus import json from typing import Any -from unittest.mock import patch +from unittest.mock import ANY, patch from aiohttp import ServerDisconnectedError, web from aiohttp.test_utils import TestClient import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant import const, core as ha from homeassistant.auth.models import Credentials from homeassistant.bootstrap import DATA_LOGGING +from homeassistant.components.group import DOMAIN as DOMAIN_GROUP +from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER +from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH from homeassistant.core import HomeAssistant +from homeassistant.loader import Integration from homeassistant.setup import async_setup_component +from homeassistant.util.yaml.loader import JSON_TYPE from tests.common import CLIENT_ID, MockUser, async_mock_service from tests.typing import ClientSessionGenerator @@ -129,6 +135,28 @@ async def test_api_state_change_with_bad_data( assert resp.status == HTTPStatus.BAD_REQUEST +async def test_api_state_change_with_invalid_json( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if send invalid json data.""" + resp = await mock_api_client.post("/api/states/test.test", data="{,}") + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "Invalid JSON specified."} + + +async def test_api_state_change_with_string_body( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we send a string instead of a JSON object.""" + resp = await mock_api_client.post( + "/api/states/bad.entity.id", json='"{"state": "new_state"}"' + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "State data should be a JSON object."} + + async def test_api_state_change_to_zero_value( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -293,17 +321,72 @@ async def test_api_get_event_listeners( async def test_api_get_services( - hass: HomeAssistant, mock_api_client: TestClient + hass: HomeAssistant, + mock_api_client: TestClient, + snapshot: SnapshotAssertion, ) -> None: """Test if we can get a dict describing current services.""" + # Set up an integration that has services + assert await async_setup_component(hass, DOMAIN_GROUP, {DOMAIN_GROUP: {}}) + + # Set up an integration that has no services + assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {}) + resp = await mock_api_client.get(const.URL_API_SERVICES) data = await resp.json() - local_services = hass.services.async_services() - for serv_domain in data: - local = local_services.pop(serv_domain["domain"]) + assert data == snapshot - assert serv_domain["services"].keys() == local.keys() + # Set up an integration with legacy translations in services.yaml + def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: + return { + "set_default_level": { + "description": "Translated description", + "fields": { + "level": { + "description": "Field description", + "example": "Field example", + "name": "Field name", + "selector": { + "select": { + "options": [ + "debug", + "info", + "warning", + "error", + "fatal", + "critical", + ], + "translation_key": "level", + } + }, + } + }, + "name": "Translated name", + }, + "set_level": None, + } + + await async_setup_component(hass, DOMAIN_LOGGER, {DOMAIN_LOGGER: {}}) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.helpers.service._load_services_file", + side_effect=_load_services_file, + ), + patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={}, + ), + ): + resp = await mock_api_client.get(const.URL_API_SERVICES) + + data2 = await resp.json() + + assert data2 == [*data, {"domain": DOMAIN_LOGGER, "services": ANY}] + + assert data2[-1] == snapshot async def test_api_call_service_no_data( @@ -529,6 +612,31 @@ async def test_api_template_error( assert resp.status == HTTPStatus.BAD_REQUEST +async def test_api_template_with_invalid_json( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if send invalid json data.""" + resp = await mock_api_client.post(const.URL_API_TEMPLATE, data="{,}") + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "Invalid JSON specified."} + + +async def test_api_template_error_with_string_body( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test that the API returns an appropriate error when a string is sent in the body.""" + hass.states.async_set("sensor.temperature", 10) + + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json='"{"template": "{{ states.sensor.temperature.state"}"', + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "Template data should be a JSON object."} + + async def test_stream(hass: HomeAssistant, mock_api_client: TestClient) -> None: """Test the stream.""" listen_count = _listen_count(hass) diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index ca4af1b00a3..31bb41790e5 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -11,6 +11,7 @@ from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityPlatformState from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockEntityPlatform @@ -80,6 +81,7 @@ def player_fixture(hass: HomeAssistant, state: State) -> ArcamFmj: player.entity_id = MOCK_ENTITY_ID player.hass = hass player.platform = MockEntityPlatform(hass) + player._platform_state = EntityPlatformState.ADDED player.async_write_ha_state = Mock() return player diff --git a/tests/components/arve/snapshots/test_sensor.ambr b/tests/components/arve/snapshots/test_sensor.ambr index eb51aa8c1f2..18643ac1755 100644 --- a/tests/components/arve/snapshots/test_sensor.ambr +++ b/tests/components/arve/snapshots/test_sensor.ambr @@ -144,7 +144,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_PM10', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[entry_pm2_5] @@ -181,7 +181,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test-serial-number_PM25', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[entry_temperature] @@ -314,7 +314,7 @@ 'device_class': 'pm10', 'friendly_name': 'Test Sensor PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.test_sensor_pm10', @@ -330,7 +330,7 @@ 'device_class': 'pm25', 'friendly_name': 'Test Sensor PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.test_sensor_pm2_5', diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index e20452a1f93..681f6e7759d 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components import stt, tts, wake_word +from homeassistant.components import conversation, stt, tts, wake_word from homeassistant.components.assist_pipeline import DOMAIN, select as assist_select from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, @@ -295,6 +295,11 @@ async def init_supporting_components( assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component(hass, "conversation", {"conversation": {}}) + + # Disable fuzzy matching by default for tests + agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index 7f760d069e6..b6354b2342b 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_chat_log_tts_streaming[to_stream_deltas0-0-] +# name: test_chat_log_tts_streaming[to_stream_deltas0-1-hello, how are you?] list([ dict({ 'data': dict({ @@ -467,6 +467,7 @@ 'chat_log_delta': dict({ 'tool_calls': list([ dict({ + 'external': False, 'id': 'test_tool_id', 'tool_args': dict({ }), diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 0294f9953db..a6a449bddd4 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -12,7 +12,7 @@ import hass_nabucasa import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, stt +from homeassistant.components import assist_pipeline, conversation, stt from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, CONF_DEBUG_RECORDING_DIR, @@ -116,7 +116,7 @@ async def test_pipeline_from_audio_stream_legacy( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -184,7 +184,7 @@ async def test_pipeline_from_audio_stream_entity( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -252,7 +252,7 @@ async def test_pipeline_from_audio_stream_no_stt( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 9ea3802d9f6..0cb67302700 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -29,7 +29,6 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, async_update_pipeline, ) from homeassistant.const import MATCH_ALL @@ -162,12 +161,6 @@ async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored pipelines on start.""" - async_migrate_engine( - hass, - "conversation", - conversation.OLD_HOME_ASSISTANT_AGENT, - conversation.HOME_ASSISTANT_AGENT, - ) id_1 = "01GX8ZWBAQYWNB1XV3EXEZ75DY" hass_storage[STORAGE_KEY] = { "version": STORAGE_VERSION, @@ -176,7 +169,7 @@ async def test_loading_pipelines_from_storage( "data": { "items": [ { - "conversation_engine": conversation.OLD_HOME_ASSISTANT_AGENT, + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "language_1", "id": id_1, "language": "language_1", @@ -382,7 +375,7 @@ async def test_get_pipelines(hass: HomeAssistant) -> None: ("en", "us", "en", "en"), ("en", "uk", "en", "en"), ("pt", "pt", "pt", "pt"), - ("pt", "br", "pt-br", "pt"), + ("pt", "br", "pt-BR", "pt"), ], ) async def test_default_pipeline_no_stt_tts( @@ -435,7 +428,7 @@ async def test_default_pipeline_no_stt_tts( ("en", "us", "en", "en", "en", "en"), ("en", "uk", "en", "en", "en", "en"), ("pt", "pt", "pt", "pt", "pt", "pt"), - ("pt", "br", "pt-br", "pt", "pt-br", "pt-br"), + ("pt", "br", "pt-BR", "pt", "pt-br", "pt-br"), ], ) @pytest.mark.usefixtures("init_supporting_components") @@ -668,43 +661,6 @@ async def test_update_pipeline( } -@pytest.mark.usefixtures("init_supporting_components") -async def test_migrate_after_load(hass: HomeAssistant) -> None: - """Test migrating an engine after done loading.""" - assert await async_setup_component(hass, "assist_pipeline", {}) - - pipeline_data: PipelineData = hass.data[DOMAIN] - store = pipeline_data.pipeline_store - assert len(store.data) == 1 - - assert ( - await async_create_default_pipeline( - hass, - stt_engine_id="bla", - tts_engine_id="bla", - pipeline_name="Bla pipeline", - ) - is None - ) - pipeline = await async_create_default_pipeline( - hass, - stt_engine_id="test", - tts_engine_id="test", - pipeline_name="Test pipeline", - ) - assert pipeline is not None - - async_migrate_engine(hass, "stt", "test", "stt.test") - async_migrate_engine(hass, "tts", "test", "tts.test") - - await hass.async_block_till_done(wait_background_tasks=True) - - pipeline_updated = async_get_pipeline(hass, pipeline.id) - - assert pipeline_updated.stt_engine == "stt.test" - assert pipeline_updated.tts_engine == "tts.test" - - def test_fallback_intent_filter() -> None: """Test that we filter the right things.""" assert ( @@ -1110,6 +1066,7 @@ async def test_sentence_trigger_overrides_conversation_agent( None, ) assert (intent_end_event is not None) and intent_end_event.data + assert intent_end_event.data["processed_locally"] is True assert ( intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ "speech" @@ -1192,6 +1149,7 @@ async def test_prefer_local_intents( None, ) assert (intent_end_event is not None) and intent_end_event.data + assert intent_end_event.data["processed_locally"] is True assert ( intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ "speech" @@ -1362,7 +1320,7 @@ async def test_stt_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1438,7 +1396,7 @@ async def test_tts_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1514,7 +1472,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1592,9 +1550,9 @@ async def test_pipeline_language_used_instead_of_conversation_language( "?", ], ), - # We are not streaming, so 0 chunks via streaming method - 0, - "", + # We always stream when possible, so 1 chunk via streaming method + 1, + "hello, how are you?", ), # Size above STREAM_RESPONSE_CHUNKS ( diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 3473b23bedd..4b7a11edfee 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -235,6 +235,43 @@ async def test_new_pipeline_cancels_pipeline( preannounce_media_id="http://example.com/preannounce.mp3", ), ), + ( + { + "message": "Hello", + "media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + tts_token=None, + media_id_source="media_id", + ), + ), + ( + { + "media_id": { + "media_content_id": "http://example.com/bla.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -610,6 +647,51 @@ async def test_vad_sensitivity_entity_not_found( ), ), ), + ( + { + "start_message": "Hello", + "start_media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + ( + "mock-conversation-id", + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + { + "start_media_id": { + "media_content_id": "http://example.com/given.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") @@ -711,12 +793,19 @@ async def test_start_conversation_default_preannounce( @pytest.mark.parametrize( - ("service_data", "response_text", "expected_answer"), + ("service_data", "response_text", "expected_answer", "should_preannounce"), [ + ( + {}, + "jazz", + AssistSatelliteAnswer(id=None, sentence="jazz"), + True, + ), ( {"preannounce": False}, "jazz", AssistSatelliteAnswer(id=None, sentence="jazz"), + False, ), ( { @@ -728,9 +817,14 @@ async def test_start_conversation_default_preannounce( }, "Some Rock, please.", AssistSatelliteAnswer(id="rock", sentence="Some Rock, please."), + False, ), ( { + "question_media_id": { + "media_content_id": "media-source://tts/cloud?message=What+kind+of+music+would+you+like+to+listen+to%3F&language=en-US&gender=female", + "media_content_type": "provider", + }, "answers": [ { "id": "genre", @@ -741,7 +835,7 @@ async def test_start_conversation_default_preannounce( "sentences": ["artist {artist} [please]"], }, ], - "preannounce": False, + "preannounce": True, }, "artist Pink Floyd", AssistSatelliteAnswer( @@ -749,6 +843,7 @@ async def test_start_conversation_default_preannounce( sentence="artist Pink Floyd", slots={"artist": "Pink Floyd"}, ), + True, ), ], ) @@ -759,6 +854,7 @@ async def test_ask_question( service_data: dict, response_text: str, expected_answer: AssistSatelliteAnswer, + should_preannounce: bool, ) -> None: """Test asking a question on a device and matching an answer.""" entity_id = "assist_satellite.test_entity" @@ -782,6 +878,9 @@ async def test_ask_question( async def async_start_conversation(start_announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING + assert ( + start_announcement.preannounce_media_id is not None + ) is should_preannounce await original_start_conversation(start_announcement) audio_stream = object() diff --git a/tests/components/asuswrt/common.py b/tests/components/asuswrt/common.py index d3953416281..541e74e5b39 100644 --- a/tests/components/asuswrt/common.py +++ b/tests/components/asuswrt/common.py @@ -1,7 +1,8 @@ """Test code shared between test files.""" +from unittest.mock import MagicMock + from aioasuswrt.asuswrt import Device as LegacyDevice -from pyasuswrt.asuswrt import Device as HttpDevice from homeassistant.components.asuswrt.const import ( CONF_SSH_KEY, @@ -59,8 +60,22 @@ MOCK_MACS = [ ] -def new_device(protocol, mac, ip, name): +def make_client(mac, ip, name, node): + """Create a modern mock client.""" + connection = MagicMock() + connection.ip_address = ip + connection.node = node + description = MagicMock() + description.name = name + description.mac = mac + client = MagicMock() + client.connection = connection + client.description = description + return client + + +def new_device(protocol, mac, ip, name, node=None): """Return a new device for specific protocol.""" if protocol in [PROTOCOL_HTTP, PROTOCOL_HTTPS]: - return HttpDevice(mac, ip, name, ROUTER_MAC_ADDR, None) + return make_client(mac, ip, name, node) return LegacyDevice(mac, ip, name) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index f850a26b997..95c8f3dbf74 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -1,17 +1,25 @@ """Fixtures for Asuswrt component.""" +from datetime import datetime from unittest.mock import Mock, patch from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aioasuswrt.connection import TelnetConnection -from pyasuswrt.asuswrt import AsusWrtError, AsusWrtHttp +from asusrouter import AsusRouter, AsusRouterError +from asusrouter.modules.data import AsusData +from asusrouter.modules.identity import AsusDevice import pytest -from homeassistant.components.asuswrt.const import PROTOCOL_HTTP, PROTOCOL_SSH +from .common import ( + ASUSWRT_BASE, + MOCK_MACS, + PROTOCOL_HTTP, + PROTOCOL_SSH, + ROUTER_MAC_ADDR, + new_device, +) -from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device - -ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtHttp" +ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusRouter" ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" MOCK_BYTES_TOTAL = 60000000000, 50000000000 @@ -29,8 +37,20 @@ MOCK_CPU_USAGE = { } MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) +# Mock for AsusData.NETWORK return of both rates and total bytes +MOCK_CURRENT_NETWORK = { + "sensor_rx_rates": MOCK_CURRENT_TRANSFER_RATES[0] * 8, # AR works with bits + "sensor_tx_rates": MOCK_CURRENT_TRANSFER_RATES[1] * 8, # AR works with bits + "sensor_rx_bytes": MOCK_BYTES_TOTAL[0], + "sensor_tx_bytes": MOCK_BYTES_TOTAL[1], +} MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) +MOCK_SYSINFO = { + "sensor_load_avg1": MOCK_LOAD_AVG[0], + "sensor_load_avg5": MOCK_LOAD_AVG[1], + "sensor_load_avg15": MOCK_LOAD_AVG[2], +} MOCK_MEMORY_USAGE = { "mem_usage_perc": 52.4, "mem_total": 1048576, @@ -40,6 +60,10 @@ MOCK_MEMORY_USAGE = { MOCK_TEMPERATURES = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} MOCK_TEMPERATURES_HTTP = {**MOCK_TEMPERATURES, "5.0GHz_2": 40.3, "6.0GHz": 40.4} MOCK_UPTIME = {"last_boot": "2024-08-02T00:47:00+00:00", "uptime": 1625927} +MOCK_BOOTTIME = { + "sensor_last_boot": datetime.fromisoformat(MOCK_UPTIME["last_boot"]), + "sensor_uptime": MOCK_UPTIME["uptime"], +} @pytest.fixture(name="patch_setup_entry") @@ -62,10 +86,14 @@ def mock_devices_legacy_fixture(): @pytest.fixture(name="mock_devices_http") def mock_devices_http_fixture(): - """Mock a list of devices.""" + """Mock a list of AsusRouter client devices for HTTP backend.""" return { - MOCK_MACS[0]: new_device(PROTOCOL_HTTP, MOCK_MACS[0], "192.168.1.2", "Test"), - MOCK_MACS[1]: new_device(PROTOCOL_HTTP, MOCK_MACS[1], "192.168.1.3", "TestTwo"), + MOCK_MACS[0]: new_device( + PROTOCOL_HTTP, MOCK_MACS[0], "192.168.1.2", "Test", "node1" + ), + MOCK_MACS[1]: new_device( + PROTOCOL_HTTP, MOCK_MACS[1], "192.168.1.3", "TestTwo", "node2" + ), } @@ -121,57 +149,90 @@ def mock_controller_connect_legacy_sens_fail(connect_legacy): @pytest.fixture(name="connect_http") def mock_controller_connect_http(mock_devices_http): """Mock a successful connection with http library.""" - with patch(ASUSWRT_HTTP_LIB, spec_set=AsusWrtHttp) as service_mock: - service_mock.return_value.is_connected = True - service_mock.return_value.mac = ROUTER_MAC_ADDR - service_mock.return_value.model = "FAKE_MODEL" - service_mock.return_value.firmware = "FAKE_FIRMWARE" - service_mock.return_value.async_get_connected_devices.return_value = ( - mock_devices_http + with patch(ASUSWRT_HTTP_LIB, spec_set=AsusRouter) as service_mock: + instance = service_mock.return_value + + # Simulate connection status + instance.connected = True + + # Identity + instance.async_get_identity.return_value = AsusDevice( + mac=ROUTER_MAC_ADDR, + model="FAKE_MODEL", + firmware="FAKE_FIRMWARE", ) - service_mock.return_value.async_get_traffic_bytes.return_value = ( - MOCK_BYTES_TOTAL_HTTP - ) - service_mock.return_value.async_get_traffic_rates.return_value = ( - MOCK_CURRENT_TRANSFER_RATES_HTTP - ) - service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG_HTTP - service_mock.return_value.async_get_temperatures.return_value = { - k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" - } - service_mock.return_value.async_get_cpu_usage.return_value = MOCK_CPU_USAGE - service_mock.return_value.async_get_memory_usage.return_value = ( - MOCK_MEMORY_USAGE - ) - service_mock.return_value.async_get_uptime.return_value = MOCK_UPTIME + + # Data fetches via async_get_data + instance.async_get_data.side_effect = lambda datatype, *args, **kwargs: { + AsusData.CLIENTS: mock_devices_http, + AsusData.NETWORK: MOCK_CURRENT_NETWORK, + AsusData.SYSINFO: MOCK_SYSINFO, + AsusData.TEMPERATURE: { + k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" + }, + AsusData.CPU: MOCK_CPU_USAGE, + AsusData.RAM: MOCK_MEMORY_USAGE, + AsusData.BOOTTIME: MOCK_BOOTTIME, + }[datatype] + yield service_mock +def make_async_get_data_side_effect(fail_types=None): + """Return a side effect for async_get_data that fails for specified AsusData types.""" + fail_types = set(fail_types or []) + + def side_effect(datatype, *args, **kwargs): + if datatype in fail_types: + raise AsusRouterError(f"{datatype} unavailable") + # Return valid mock data for other types + if datatype == AsusData.CLIENTS: + return {} + if datatype == AsusData.NETWORK: + return {} + if datatype == AsusData.SYSINFO: + return {} + if datatype == AsusData.TEMPERATURE: + return {} + if datatype == AsusData.CPU: + return {} + if datatype == AsusData.RAM: + return {} + if datatype == AsusData.BOOTTIME: + return {} + return {} + + return side_effect + + @pytest.fixture(name="connect_http_sens_fail") def mock_controller_connect_http_sens_fail(connect_http): - """Mock a successful connection using http library with sensors fail.""" - connect_http.return_value.mac = None - connect_http.return_value.async_get_connected_devices.side_effect = AsusWrtError - connect_http.return_value.async_get_traffic_bytes.side_effect = AsusWrtError - connect_http.return_value.async_get_traffic_rates.side_effect = AsusWrtError - connect_http.return_value.async_get_loadavg.side_effect = AsusWrtError - connect_http.return_value.async_get_temperatures.side_effect = AsusWrtError - connect_http.return_value.async_get_cpu_usage.side_effect = AsusWrtError - connect_http.return_value.async_get_memory_usage.side_effect = AsusWrtError - connect_http.return_value.async_get_uptime.side_effect = AsusWrtError + """Universal fixture to fail specified AsusData types.""" + + def _set_fail_types(fail_types): + connect_http.return_value.async_get_data.side_effect = ( + make_async_get_data_side_effect(fail_types) + ) + return connect_http + + return _set_fail_types @pytest.fixture(name="connect_http_sens_detect") def mock_controller_connect_http_sens_detect(): """Mock a successful sensor detection using http library.""" - with ( - patch( - f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", - return_value=[*MOCK_TEMPERATURES_HTTP], - ) as mock_sens_temp_detect, - patch( - f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_cpu_sensors", - return_value=[*MOCK_CPU_USAGE], - ) as mock_sens_cpu_detect, - ): - yield mock_sens_temp_detect, mock_sens_cpu_detect + + async def _get_sensors_side_effect(datatype): + if datatype == AsusData.TEMPERATURE: + return list(MOCK_TEMPERATURES_HTTP) + if datatype == AsusData.CPU: + return list(MOCK_CPU_USAGE) + if datatype == AsusData.SYSINFO: + return list(MOCK_SYSINFO) + return [] + + with patch( + f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_sensors", + side_effect=_get_sensors_side_effect, + ) as mock_sens_detect: + yield mock_sens_detect diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index 83c3204d239..314bf030dbc 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -3,7 +3,8 @@ from socket import gaierror from unittest.mock import patch -from pyasuswrt import AsusWrtError +from asusrouter import AsusRouterError +from asusrouter.modules.identity import AsusDevice import pytest from homeassistant.components.asuswrt.const import ( @@ -128,7 +129,11 @@ async def test_user_http( assert flow_result["type"] is FlowResultType.FORM assert flow_result["step_id"] == "user" - connect_http.return_value.mac = unique_id + connect_http.return_value.async_get_identity.return_value = AsusDevice( + mac=unique_id, + model="FAKE_MODEL", + firmware="FAKE_FIRMWARE", + ) # test with all provided result = await hass.config_entries.flow.async_configure( @@ -297,7 +302,7 @@ async def test_on_connect_legacy_failed( @pytest.mark.parametrize( ("side_effect", "error"), [ - (AsusWrtError, "cannot_connect"), + (AsusRouterError, "cannot_connect"), (TypeError, "unknown"), (None, "cannot_connect"), ], @@ -311,7 +316,7 @@ async def test_on_connect_http_failed( context={"source": SOURCE_USER, "show_advanced_options": True}, ) - connect_http.return_value.is_connected = False + connect_http.return_value.connected = False connect_http.return_value.async_connect.side_effect = side_effect result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/asuswrt/test_helpers.py b/tests/components/asuswrt/test_helpers.py new file mode 100644 index 00000000000..6573ab9361c --- /dev/null +++ b/tests/components/asuswrt/test_helpers.py @@ -0,0 +1,95 @@ +"""Tests for AsusWRT helpers.""" + +from typing import Any + +import pytest + +from homeassistant.components.asuswrt.helpers import clean_dict, translate_to_legacy + +DICT_TO_CLEAN = { + "key1": "value1", + "key2": None, + "key3_state": "value3", + "key4_state": None, + "state": None, +} + +DICT_CLEAN = { + "key1": "value1", + "key3_state": "value3", + "key4_state": None, + "state": None, +} + +TRANSLATE_0_INPUT = { + "usage": "value1", + "cpu": "value2", +} + +TRANSLATE_0_OUTPUT = { + "mem_usage_perc": "value1", + "CPU": "value2", +} + +TRANSLATE_1_INPUT = { + "wan_rx": "value1", + "wan_rrx": "value2", +} + +TRANSLATE_1_OUTPUT = { + "sensor_rx_bytes": "value1", + "wan_rrx": "value2", +} + +TRANSLATE_2_INPUT = [ + "free", + "used", +] + +TRANSLATE_2_OUTPUT = [ + "mem_free", + "mem_used", +] + +TRANSLATE_3_INPUT = [ + "2ghz", + "2ghz2", +] + +TRANSLATE_3_OUTPUT = [ + "2.4GHz", + "2ghz2", +] + + +def test_clean_dict() -> None: + """Test clean_dict method.""" + + assert clean_dict(DICT_TO_CLEAN) == DICT_CLEAN + + +@pytest.mark.parametrize( + ("input", "expected"), + [ + # Case set 0: None as input -> None on output + (None, None), + # Case set 1: Dict structure should stay intact or translated + ({"key1": "value1", "key2": None}, {"key1": "value1", "key2": None}), + (TRANSLATE_0_INPUT, TRANSLATE_0_OUTPUT), + (TRANSLATE_1_INPUT, TRANSLATE_1_OUTPUT), + ({}, {}), + # Case set 2: List structure should stay intact or translated + (["key1", "key2"], ["key1", "key2"]), + (TRANSLATE_2_INPUT, TRANSLATE_2_OUTPUT), + (TRANSLATE_3_INPUT, TRANSLATE_3_OUTPUT), + ([], []), + # Case set 3: Anything else should be simply returned + (123, 123), + ("string", "string"), + (3.1415926535, 3.1415926535), + ], +) +def test_translate(input: Any, expected: Any) -> None: + """Test translate method.""" + + assert translate_to_legacy(input) == expected diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 929500f0bb7..c782605aab3 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -2,8 +2,9 @@ from datetime import timedelta +from asusrouter import AsusRouterError +from asusrouter.modules.data import AsusData from freezegun.api import FrozenDateTimeFactory -from pyasuswrt.exceptions import AsusWrtError, AsusWrtNotAvailableInfoError import pytest from homeassistant.components import device_tracker, sensor @@ -39,6 +40,7 @@ from .common import ( ROUTER_MAC_ADDR, new_device, ) +from .conftest import make_async_get_data_side_effect from tests.common import MockConfigEntry, async_fire_time_changed @@ -260,8 +262,8 @@ async def test_loadavg_sensors_unaivalable_http( config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_LOAD_AVG) config_entry.add_to_hass(hass) - connect_http.return_value.async_get_loadavg.side_effect = ( - AsusWrtNotAvailableInfoError + connect_http.return_value.async_get_data.side_effect = ( + make_async_get_data_side_effect([AsusData.SYSINFO]) ) # initial devices setup @@ -281,6 +283,7 @@ async def test_temperature_sensors_http_fail( hass: HomeAssistant, connect_http_sens_fail ) -> None: """Test fail creating AsusWRT temperature sensors.""" + _ = connect_http_sens_fail([AsusData.TEMPERATURE]) config_entry, sensor_prefix = _setup_entry( hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES ) @@ -347,6 +350,7 @@ async def test_cpu_sensors_http_fail( hass: HomeAssistant, connect_http_sens_fail ) -> None: """Test fail creating AsusWRT cpu sensors.""" + _ = connect_http_sens_fail([AsusData.CPU]) config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) config_entry.add_to_hass(hass) @@ -367,7 +371,10 @@ async def test_cpu_sensors_http_fail( async def test_cpu_sensors_http( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + connect_http, + connect_http_sens_detect, ) -> None: """Test creating AsusWRT cpu sensors.""" config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) @@ -461,7 +468,7 @@ async def test_connect_fail_legacy( @pytest.mark.parametrize( "side_effect", - [AsusWrtError, None], + [AsusRouterError, None], ) async def test_connect_fail_http( hass: HomeAssistant, connect_http, side_effect @@ -476,7 +483,7 @@ async def test_connect_fail_http( config_entry.add_to_hass(hass) connect_http.return_value.async_connect.side_effect = side_effect - connect_http.return_value.is_connected = False + connect_http.return_value.connected = False # initial setup fail await hass.config_entries.async_setup(config_entry.entry_id) @@ -524,6 +531,16 @@ async def test_sensors_polling_fails_http( connect_http_sens_detect, ) -> None: """Test AsusWRT sensors are unavailable when polling fails.""" + # Fail all relevant AsusData types for HTTP sensors + fail_types = [ + AsusData.NETWORK, + AsusData.CPU, + AsusData.SYSINFO, + AsusData.RAM, + AsusData.TEMPERATURE, + AsusData.BOOTTIME, + ] + _ = connect_http_sens_fail(fail_types) await _test_sensors_polling_fails(hass, freezer, CONFIG_DATA_HTTP, SENSORS_ALL_HTTP) diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index be5947372f5..9d94ae9ffdc 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -17,7 +17,6 @@ 'tmt100', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'August Home Inc.', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'tmt100 Name', 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, }) diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index 0a594fed1ee..8af45cae68c 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -21,7 +21,6 @@ 'online_with_doorsense', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'August Home Inc.', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'online_with_doorsense Name', 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, }) diff --git a/tests/components/aws_s3/test_backup.py b/tests/components/aws_s3/test_backup.py index bf5baf2044b..aa8725a01b3 100644 --- a/tests/components/aws_s3/test_backup.py +++ b/tests/components/aws_s3/test_backup.py @@ -23,7 +23,6 @@ from homeassistant.components.aws_s3.const import ( ) from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -43,7 +42,6 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index 9e407bfef0b..663c52dd36c 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -21,7 +21,6 @@ '00:40:8c:12:34:56', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Axis Communications AB', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', - 'suggested_area': None, 'sw_version': '9.10.1', 'via_device_id': None, }) @@ -58,7 +56,6 @@ '00:40:8c:12:34:56', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Axis Communications AB', @@ -68,7 +65,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', - 'suggested_area': None, 'sw_version': '9.80.1', 'via_device_id': None, }) diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py index 8fb81e7dbc4..d7fb6981878 100644 --- a/tests/components/azure_storage/test_backup.py +++ b/tests/components/azure_storage/test_backup.py @@ -19,7 +19,6 @@ from homeassistant.components.azure_storage.const import ( ) from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -39,7 +38,6 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 3197cbfadeb..d9533d2764d 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -19,7 +19,6 @@ from homeassistant.components.backup import ( from homeassistant.components.backup.backup import CoreLocalBackupAgent from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.common import mock_platform @@ -132,12 +131,15 @@ async def setup_backup_integration( ) -> dict[str, Mock]: """Set up the Backup integration.""" backups = backups or {} - async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( "homeassistant.components.backup.backup.is_hassio", return_value=with_hassio ), + patch( + "homeassistant.components.backup.services.is_hassio", + return_value=with_hassio, + ), ): remote_agents = remote_agents or [] remote_agents_dict = {} diff --git a/tests/components/backup/snapshots/test_diagnostics.ambr b/tests/components/backup/snapshots/test_diagnostics.ambr index cf412970204..a1ee55f07f1 100644 --- a/tests/components/backup/snapshots/test_diagnostics.ambr +++ b/tests/components/backup/snapshots/test_diagnostics.ambr @@ -31,7 +31,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index aa9ccde4b8a..b82bb7c650f 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -88,7 +88,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -187,7 +186,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -306,7 +304,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -413,7 +410,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -520,7 +516,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -633,7 +628,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -758,7 +752,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 1ce16b2c7d3..2bac144a258 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1306,7 +1306,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -1423,7 +1422,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -1540,7 +1538,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -1671,7 +1668,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -1949,7 +1945,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2064,7 +2059,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2179,7 +2173,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2296,7 +2289,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': '06:00:00', }), }), @@ -2415,7 +2407,6 @@ 'mon', ]), 'recurrence': 'custom_days', - 'state': 'never', 'time': None, }), }), @@ -2532,7 +2523,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2653,7 +2643,6 @@ 'sun', ]), 'recurrence': 'custom_days', - 'state': 'never', 'time': None, }), }), @@ -2778,7 +2767,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -2895,7 +2883,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -3012,7 +2999,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -3129,7 +3115,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -3246,7 +3231,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -6299,20 +6283,3 @@ 'type': 'event', }) # --- -# name: test_subscribe_event_early - dict({ - 'event': dict({ - 'manager_state': 'idle', - }), - 'id': 1, - 'type': 'event', - }) -# --- -# name: test_subscribe_event_early.1 - dict({ - 'id': 1, - 'result': None, - 'success': True, - 'type': 'result', - }) -# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 5a33bf39390..0624839336c 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -14,7 +14,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .common import ( @@ -64,7 +63,6 @@ async def test_load_backups( side_effect: Exception | None, ) -> None: """Test load backups.""" - async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -84,7 +82,6 @@ async def test_upload( hass_client: ClientSessionGenerator, ) -> None: """Test upload backup.""" - async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -140,7 +137,6 @@ async def test_delete_backup( unlink_path: Path | None, ) -> None: """Test delete backup.""" - async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py index 51d704b8ba5..c36ec5eb4f7 100644 --- a/tests/components/backup/test_onboarding.py +++ b/tests/components/backup/test_onboarding.py @@ -10,7 +10,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import backup, onboarding from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.common import register_auth_provider @@ -57,7 +56,6 @@ async def test_onboarding_view_after_done( mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -111,7 +109,6 @@ async def test_onboarding_backup_info( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -232,7 +229,6 @@ async def test_onboarding_backup_restore( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -329,7 +325,6 @@ async def test_onboarding_backup_restore_error( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -373,7 +368,6 @@ async def test_onboarding_backup_restore_unexpected_error( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -399,7 +393,6 @@ async def test_onboarding_backup_upload( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 34e562ecfd6..ba19abdbb34 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -30,8 +30,6 @@ from homeassistant.components.backup.manager import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.backup import async_initialize_backup -from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -78,7 +76,7 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "copies": None, "days": None, }, - "schedule": {"days": [], "recurrence": "never", "state": "never", "time": None}, + "schedule": {"days": [], "recurrence": "never", "time": None}, }, } DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] @@ -1011,7 +1009,6 @@ async def test_agents_info( "schedule": { "days": DAILY, "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1043,7 +1040,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1075,7 +1071,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1107,7 +1102,6 @@ async def test_agents_info( "schedule": { "days": ["mon"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1139,7 +1133,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1171,7 +1164,6 @@ async def test_agents_info( "schedule": { "days": ["mon", "sun"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1206,7 +1198,6 @@ async def test_agents_info( "schedule": { "days": ["mon", "sun"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1238,7 +1229,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1270,7 +1260,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1311,7 +1300,6 @@ async def test_agents_info( "schedule": { "days": ["mon", "sun"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1962,7 +1950,6 @@ async def test_config_schedule_logic( "schedule": { "days": [], "recurrence": "daily", - "state": "never", "time": None, }, }, @@ -2872,7 +2859,6 @@ async def test_config_retention_copies_logic( "schedule": { "days": [], "recurrence": "daily", - "state": "never", "time": None, }, }, @@ -3151,7 +3137,6 @@ async def test_config_retention_copies_logic_manual_backup( "schedule": { "days": [], "recurrence": "daily", - "state": "never", "time": None, }, }, @@ -3816,7 +3801,6 @@ async def test_config_retention_days_logic( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -3888,7 +3872,6 @@ async def test_configured_agents_unavailable_repair( "schedule": { "days": ["mon"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -4057,29 +4040,6 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot -async def test_subscribe_event_early( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test subscribe event before backup integration has started.""" - async_initialize_backup(hass) - await setup_backup_integration(hass, with_hassio=False) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/subscribe_events"}) - assert await client.receive_json() == snapshot - - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - - manager.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) - ) - assert await client.receive_json() == snapshot - - @pytest.mark.parametrize( ("agent_id", "backup_id", "password"), [ diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr index 3b748d3a27a..a7fc2c88e49 100644 --- a/tests/components/bang_olufsen/snapshots/test_event.ambr +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -5,10 +5,10 @@ 'event.beosound_balance_11111111_microphone', 'event.beosound_balance_11111111_next', 'event.beosound_balance_11111111_play_pause', - 'event.beosound_balance_11111111_favourite_1', - 'event.beosound_balance_11111111_favourite_2', - 'event.beosound_balance_11111111_favourite_3', - 'event.beosound_balance_11111111_favourite_4', + 'event.beosound_balance_11111111_favorite_1', + 'event.beosound_balance_11111111_favorite_2', + 'event.beosound_balance_11111111_favorite_3', + 'event.beosound_balance_11111111_favorite_4', 'event.beosound_balance_11111111_previous', 'event.beosound_balance_11111111_volume', 'media_player.beosound_balance_11111111', diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index 11f337b715f..1e5546ac5f2 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -32,7 +32,7 @@ async def test_button_event_creation( # Add Button Event entity ids entity_ids = [ f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "favourite_" + "preset", "favorite_" ) for button_type in DEVICE_BUTTONS ] diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 59fbdf9a253..254c5428806 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -182,7 +182,12 @@ async def test_get_condition_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( @@ -212,7 +217,12 @@ async def test_get_condition_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index dd71c1e5d06..e9ad5d0a1e1 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -185,7 +185,12 @@ async def test_get_trigger_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( @@ -215,7 +220,12 @@ async def test_get_trigger_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 856d9e6e8a0..e099b9c24e4 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -4,13 +4,9 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest -from homeassistant.components.blink.const import ( - ATTR_CONFIG_ENTRY_ID, - DOMAIN, - SERVICE_SEND_PIN, -) +from homeassistant.components.blink.const import DOMAIN, SERVICE_SEND_PIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 97acff39a62..402d644747a 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -4,18 +4,28 @@ from __future__ import annotations from asyncio import Event, Future from dataclasses import dataclass +from typing import Any from unittest.mock import MagicMock, patch from bluecurrent_api import Client +from homeassistant.components.blue_current import EVSE_ID, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import PUBLIC_CHARGING from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +DEFAULT_CHARGE_POINT_OPTIONS = { + PLUG_AND_CHARGE: {"value": False, "permission": "write"}, + PUBLIC_CHARGING: {"value": True, "permission": "write"}, +} + DEFAULT_CHARGE_POINT = { "evse_id": "101", "model_type": "", "name": "", + "activity": "available", + **DEFAULT_CHARGE_POINT_OPTIONS, } @@ -77,11 +87,20 @@ def create_client_mock( """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def update_charge_point( + evse_id: str, event_object: str, settings: dict[str, Any] + ) -> None: + """Update the charge point data by sending an event.""" + await client_mock.receiver( + {"object": event_object, "data": {EVSE_ID: evse_id, **settings}} + ) + client_mock.connect.side_effect = connect client_mock.wait_for_charge_points.side_effect = wait_for_charge_points client_mock.get_charge_points.side_effect = get_charge_points client_mock.get_status.side_effect = get_status client_mock.get_grid_status.side_effect = get_grid_status + client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py index cf20b7334b4..773ffbccd97 100644 --- a/tests/components/blue_current/test_sensor.py +++ b/tests/components/blue_current/test_sensor.py @@ -7,17 +7,10 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import DEFAULT_CHARGE_POINT, init_integration from tests.common import MockConfigEntry -charge_point = { - "evse_id": "101", - "model_type": "", - "name": "", -} - - charge_point_status = { "actual_v1": 14, "actual_v2": 18, @@ -97,7 +90,7 @@ async def test_sensors_created( hass, config_entry, "sensor", - charge_point, + DEFAULT_CHARGE_POINT, charge_point_status | charge_point_status_timestamps, grid, ) @@ -116,7 +109,7 @@ async def test_sensors( ) -> None: """Test the underlying sensors.""" await init_integration( - hass, config_entry, "sensor", charge_point, charge_point_status, grid + hass, config_entry, "sensor", DEFAULT_CHARGE_POINT, charge_point_status, grid ) for entity_id, key in charge_point_entity_ids.items(): diff --git a/tests/components/blue_current/test_switch.py b/tests/components/blue_current/test_switch.py new file mode 100644 index 00000000000..c7837816d75 --- /dev/null +++ b/tests/components/blue_current/test_switch.py @@ -0,0 +1,152 @@ +"""The tests for Bluecurrent switches.""" + +from homeassistant.components.blue_current import CHARGEPOINT_SETTINGS, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import ( + ACTIVITY, + CHARGEPOINT_STATUS, + PUBLIC_CHARGING, + UNAVAILABLE, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import DEFAULT_CHARGE_POINT, init_integration + +from tests.common import MockConfigEntry + + +async def test_switches( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the underlying switches.""" + + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.unique_id == switch.unique_id + + +async def test_switches_offline( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if switches are disabled when needed.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "offline" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == UNAVAILABLE + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.entity_id == switch.entity_id + + +async def test_block_switch_availability( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the block switch is unavailable when charging.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "charging" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + state = hass.states.get("switch.101_block_charge_point") + assert state and state.state == UNAVAILABLE + + +async def test_toggle( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the on / off methods and if the switch gets updated.""" + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_ON + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_OFF + + +async def test_setting_change( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the state of the switches are updated when an update message from the websocket comes in.""" + integration = await init_integration(hass, config_entry, Platform.SWITCH) + client_mock = integration[0] + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + assert state.state == STATE_OFF + + await client_mock.update_charge_point( + "101", + CHARGEPOINT_SETTINGS, + { + PLUG_AND_CHARGE: True, + PUBLIC_CHARGING: {"value": False, "permission": "write"}, + }, + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_block_charge_point") + assert plug_and_charge_switch.state == STATE_OFF + + await client_mock.update_charge_point( + "101", CHARGEPOINT_STATUS, {ACTIVITY: UNAVAILABLE} + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_UNAVAILABLE + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_UNAVAILABLE + + switch = hass.states.get("switch.101_block_charge_point") + assert switch.state == STATE_ON diff --git a/tests/components/bluemaestro/__init__.py b/tests/components/bluemaestro/__init__.py index e598eb34597..259457453b1 100644 --- a/tests/components/bluemaestro/__init__.py +++ b/tests/components/bluemaestro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 38cb3b485d4..fdfd3f6b285 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -203,6 +203,7 @@ 'light', ]), 'multiple': False, + 'reorder': False, }), }), }), @@ -217,6 +218,7 @@ 'binary_sensor', ]), 'multiple': False, + 'reorder': False, }), }), }), diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 31d301e2dac..6951a2ce4cc 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -1,11 +1,11 @@ """Tests for the Bluetooth integration.""" -from collections.abc import Iterable +from collections.abc import Generator, Iterable from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -53,7 +53,6 @@ ADVERTISEMENT_DATA_DEFAULTS = { BLE_DEVICE_DEFAULTS = { "name": None, - "rssi": -127, "details": None, } @@ -89,7 +88,6 @@ def generate_ble_device( address: str | None = None, name: str | None = None, details: Any | None = None, - rssi: int | None = None, **kwargs: Any, ) -> BLEDevice: """Generate a BLEDevice with defaults.""" @@ -100,8 +98,6 @@ def generate_ble_device( new["name"] = name if details is not None: new["details"] = details - if rssi is not None: - new["rssi"] = rssi for key, value in BLE_DEVICE_DEFAULTS.items(): new.setdefault(key, value) return BLEDevice(**new) @@ -149,6 +145,7 @@ def inject_advertisement_with_time_and_source_connectable( time: float, source: str, connectable: bool, + raw: bytes | None = None, ) -> None: """Inject an advertisement into the manager from a specific source at a time and connectable status.""" async_get_advertisement_callback(hass)( @@ -165,6 +162,7 @@ def inject_advertisement_with_time_and_source_connectable( connectable=connectable, time=time, tx_power=adv.tx_power, + raw=raw, ) ) @@ -215,34 +213,35 @@ def inject_bluetooth_service_info( @contextmanager -def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: +def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> Generator[None]: """Mock all the discovered devices from all the scanners.""" manager = _get_manager() - original_history = {} scanners = list( itertools.chain( manager._connectable_scanners, manager._non_connectable_scanners ) ) - for scanner in scanners: - data = scanner.discovered_devices_and_advertisement_data - original_history[scanner] = data.copy() - data.clear() - if scanners: - data = scanners[0].discovered_devices_and_advertisement_data - data.clear() - data.update( - {device.address: (device, MagicMock()) for device in mock_discovered} - ) - yield - for scanner in scanners: - data = scanner.discovered_devices_and_advertisement_data - data.clear() - data.update(original_history[scanner]) + if scanners and getattr(scanners[0], "scanner", None): + with patch.object( + scanners[0].scanner.__class__, + "discovered_devices_and_advertisement_data", + new=PropertyMock( + side_effect=[ + { + device.address: (device, MagicMock()) + for device in mock_discovered + }, + ] + + [{}] * (len(scanners)) + ), + ): + yield + else: + yield @contextmanager -def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> None: +def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> Generator[None]: """Mock the combined best path to discovered devices from all the scanners.""" manager = _get_manager() original_all_history = manager._all_history @@ -305,6 +304,9 @@ class MockBleakClient(BleakClient): """Mock clear_cache.""" return True + def set_disconnected_callback(self, callback, **kwargs): + """Mock set_disconnected_callback.""" + class FakeScannerMixin: def get_discovered_device_advertisement_data( diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 1468367fd9a..74373da6865 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -82,7 +82,6 @@ async def test_async_scanner_devices_by_address_connectable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -116,7 +115,6 @@ async def test_async_scanner_devices_by_address_non_connectable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 25dc1b9738d..f2aa3d87778 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -54,7 +54,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -67,7 +66,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", name_2, {}, - rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( local_name=name_2, @@ -80,7 +78,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", "wohandlonger", {}, - rssi=-100, ) switchbot_device_adv_3 = generate_advertisement_data( local_name="wohandlonger", @@ -146,7 +143,6 @@ async def test_remote_scanner_expires_connectable(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -199,7 +195,6 @@ async def test_remote_scanner_expires_non_connectable(hass: HomeAssistant) -> No "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -272,7 +267,6 @@ async def test_base_scanner_connecting_behavior(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -376,7 +370,6 @@ async def test_device_with_ten_minute_advertising_interval(hass: HomeAssistant) "44:44:33:11:23:45", "bparasite", {}, - rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", @@ -501,7 +494,6 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "bparasite", {}, - rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", @@ -545,7 +537,6 @@ async def test_remote_scanner_bluetooth_config_entry( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 540bf1bfbd1..5c4d8bda70d 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -37,7 +37,7 @@ class FakeHaScanner(FakeScannerMixin, HaScanner): """Return the discovered devices and advertisement data.""" return { "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_ble_device(name="x", address="44:44:33:11:23:45"), generate_advertisement_data(local_name="x"), ) } diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 7488aa6e33c..f34afba01ef 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -78,11 +78,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS @@ -93,11 +91,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_100 ) - switchbot_device_signal_99 = generate_ble_device( - address, "wohand_signal_99", rssi=-99 - ) + switchbot_device_signal_99 = generate_ble_device(address, "wohand_signal_99") switchbot_adv_signal_99 = generate_advertisement_data( - local_name="wohand_signal_99", service_uuids=[] + local_name="wohand_signal_99", service_uuids=[], rssi=-99 ) inject_advertisement_with_source( hass, switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS @@ -108,11 +104,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_99 ) - switchbot_device_signal_98 = generate_ble_device( - address, "wohand_good_signal", rssi=-98 - ) + switchbot_device_signal_98 = generate_ble_device(address, "wohand_good_signal") switchbot_adv_signal_98 = generate_advertisement_data( - local_name="wohand_good_signal", service_uuids=[] + local_name="wohand_good_signal", service_uuids=[], rssi=-98 ) inject_advertisement_with_source( hass, switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS @@ -805,13 +799,11 @@ async def test_goes_unavailable_connectable_only_and_recovers( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_non_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -978,7 +970,6 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1394,7 +1385,6 @@ async def test_bluetooth_rediscover( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1571,7 +1561,6 @@ async def test_bluetooth_rediscover_no_match( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1693,11 +1682,9 @@ async def test_async_register_disappeared_callback( """Test bluetooth async_register_disappeared_callback handles failures.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d36741b4d5d..af367dec187 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -124,7 +124,7 @@ async def test_wrapped_bleak_client_local_adapter_only(hass: HomeAssistant) -> N "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True ), ): - assert await client.connect() is True + await client.connect() assert client.is_connected is True client.set_disconnected_callback(lambda client: None) await client.disconnect() @@ -145,7 +145,6 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( "source": "esp32_has_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( local_name="wohand", @@ -215,7 +214,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True ), ): - assert await client.connect() is True + await client.connect() assert client.is_connected is True client.set_disconnected_callback(lambda client: None) await client.disconnect() @@ -236,10 +235,9 @@ async def test_ble_device_with_proxy_client_out_of_connections_no_scanners( "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) inject_advertisement_with_source( @@ -275,10 +273,9 @@ async def test_ble_device_with_proxy_client_out_of_connections( "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @@ -340,10 +337,9 @@ async def test_ble_device_with_proxy_clear_cache(hass: HomeAssistant) -> None: "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @@ -417,7 +413,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "source": "esp32_has_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( local_name="wohand", @@ -511,7 +506,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "source": "esp32_no_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_proxy_device_no_connection_slot_adv = generate_advertisement_data( local_name="wohand", @@ -538,7 +532,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index d5d4e7ad9d0..9c3c8c6cebb 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -17,9 +17,7 @@ from . import generate_ble_device MOCK_BLE_DEVICE = generate_ble_device( "00:00:00:00:00:00", "any", - delegate="", details={"path": "/dev/hci0/device"}, - rssi=-127, ) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index 57199d04078..f12d77913a9 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -22,6 +22,7 @@ from . import ( generate_advertisement_data, generate_ble_device, inject_advertisement_with_source, + inject_advertisement_with_time_and_source_connectable, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -38,11 +39,9 @@ async def test_subscribe_advertisements( """Test bluetooth subscribe_advertisements.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS @@ -68,12 +67,13 @@ async def test_subscribe_advertisements( "connectable": True, "manufacturer_data": {}, "name": "wohand_signal_100", - "rssi": -127, + "rssi": -100, "service_data": {}, "service_uuids": [], "source": HCI0_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, + "raw": None, } ] } @@ -85,8 +85,15 @@ async def test_subscribe_advertisements( service_uuids=[], rssi=-80, ) - inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI1_SOURCE_ADDRESS + # Inject with raw bytes data + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_signal_100, + switchbot_adv_signal_100, + time.monotonic(), + HCI1_SOURCE_ADDRESS, + True, + raw=b"\x02\x01\x06\x03\x03\x0f\x18", ) async with asyncio.timeout(1): response = await client.receive_json() @@ -103,6 +110,7 @@ async def test_subscribe_advertisements( "source": HCI1_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, + "raw": "02010603030f18", } ] } @@ -134,11 +142,9 @@ async def test_subscribe_connection_allocations( """Test bluetooth subscribe_connection_allocations.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index bfe7445f614..413c96535a6 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -92,17 +92,13 @@ class FakeBleakClient(BaseFakeBleakClient): async def connect(self, *args, **kwargs): """Connect.""" + + @property + def is_connected(self): + """Connected.""" return True -class FakeBleakClientFailsToConnect(BaseFakeBleakClient): - """Fake bleak client that fails to connect.""" - - async def connect(self, *args, **kwargs): - """Connect.""" - return False - - class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Fake bleak client that raises on connect.""" @@ -110,6 +106,11 @@ class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Connect.""" raise ConnectionError("Test exception") + @property + def is_connected(self): + """Not connected.""" + return False + def _generate_ble_device_and_adv_data( interface: str, mac: str, rssi: int @@ -119,7 +120,6 @@ def _generate_ble_device_and_adv_data( generate_ble_device( mac, "any", - delegate="", details={"path": f"/org/bluez/{interface}/dev_{mac}"}, ), generate_advertisement_data(rssi=rssi), @@ -144,16 +144,6 @@ def mock_platform_client_fixture(): yield -@pytest.fixture(name="mock_platform_client_that_fails_to_connect") -def mock_platform_client_that_fails_to_connect_fixture(): - """Fixture that mocks the platform client that fails to connect.""" - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsToConnect, - ): - yield - - @pytest.fixture(name="mock_platform_client_that_raises_on_connect") def mock_platform_client_that_raises_on_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" @@ -219,7 +209,8 @@ async def test_test_switch_adapters_when_out_of_slots( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is True + await client.connect() + assert client.is_connected is True assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 0 @@ -251,7 +242,8 @@ async def test_test_switch_adapters_when_out_of_slots( ): ble_device = hci0_device_advs["00:00:00:00:00:03"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is True + await client.connect() + assert client.is_connected is True assert release_slot_mock.call_count == 0 cancel_hci0() @@ -262,7 +254,7 @@ async def test_test_switch_adapters_when_out_of_slots( async def test_release_slot_on_connect_failure( hass: HomeAssistant, install_bleak_catcher, - mock_platform_client_that_fails_to_connect, + mock_platform_client_that_raises_on_connect, ) -> None: """Ensure the slot gets released on connection failure.""" manager = _get_manager() @@ -278,7 +270,9 @@ async def test_release_slot_on_connect_failure( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is False + with pytest.raises(ConnectionError): + await client.connect() + assert client.is_connected is False assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 @@ -335,13 +329,18 @@ async def test_passing_subclassed_str_as_address( async def connect(self, *args, **kwargs): """Connect.""" + + @property + def is_connected(self): + """Connected.""" return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): - assert await client.connect() is True + await client.connect() + assert client.is_connected is True cancel_hci0() cancel_hci1() diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index e5139b253aa..cc18173b380 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -15,7 +15,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -319,7 +318,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ45842", - macaddress=format_mac("3c:6a:2c:1c:8c:80"), + macaddress="3c6a2c1c8c80", ), ) assert result["type"] is FlowResultType.FORM @@ -365,7 +364,7 @@ async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ45842".lower(), - macaddress=format_mac("3c:6a:2c:1c:8c:80"), + macaddress="3c6a2c1c8c80", ), ) assert result["type"] is FlowResultType.ABORT @@ -382,7 +381,7 @@ async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ", - macaddress=format_mac("3c:6a:2c:1c:8c:80"), + macaddress="3c6a2c1c8c80", ), ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 0aaff0edfe7..c8ced85c933 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -11,7 +11,11 @@ from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from .common import ( @@ -202,7 +206,9 @@ async def test_old_identifiers_are_removed( async def test_smart_by_bond_device_suggested_area( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we can setup a smart by bond device and get the suggested area.""" config_entry = MockConfigEntry( @@ -241,11 +247,13 @@ async def test_smart_by_bond_device_suggested_area( device = device_registry.async_get_device(identifiers={(DOMAIN, "KXXX12345")}) assert device is not None - assert device.suggested_area == "Den" + assert device.area_id == area_registry.async_get_area_by_name("Den").id async def test_bridge_device_suggested_area( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we can setup a bridge bond device and get the suggested area.""" config_entry = MockConfigEntry( @@ -289,7 +297,7 @@ async def test_bridge_device_suggested_area( device = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert device is not None - assert device.suggested_area == "Office" + assert device.area_id == area_registry.async_get_area_by_name("Office").id async def test_device_remove_devices( diff --git a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr index e3444777ff0..7e1604127e2 100644 --- a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr @@ -168,7 +168,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -182,7 +182,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch AMAX 3000 AC Failure', + 'friendly_name': 'Bosch AMAX 3000 AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_amax_3000_ac_failure', @@ -1187,7 +1187,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1201,7 +1201,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch B5512 (US1B) AC Failure', + 'friendly_name': 'Bosch B5512 (US1B) AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_b5512_us1b_ac_failure', @@ -2206,7 +2206,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2220,7 +2220,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch Solution 3000 AC Failure', + 'friendly_name': 'Bosch Solution 3000 AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_solution_3000_ac_failure', diff --git a/tests/components/bosch_alarm/test_services.py b/tests/components/bosch_alarm/test_services.py index 7b5088f32c3..059b01c1e3b 100644 --- a/tests/components/bosch_alarm/test_services.py +++ b/tests/components/bosch_alarm/test_services.py @@ -9,11 +9,11 @@ import pytest import voluptuous as vol from homeassistant.components.bosch_alarm.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index de76c00cd23..e6bc20a2216 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -21,7 +21,7 @@ 'source': 'user', 'subentries': list([ ]), - 'title': 'Mock Title', + 'title': 'BRAVIA TV-Model', 'unique_id': 'very_unique_string', 'version': 1, }), diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 497e88053f5..e59d0b6805b 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -143,7 +143,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "1234", @@ -340,7 +340,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "1234", @@ -381,7 +381,7 @@ async def test_create_entry_psk(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == "very_unique_string" - assert result["title"] == "TV-Model" + assert result["title"] == "BRAVIA TV-Model" assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "mypsk", diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py index 2f6df722909..ecaa82678e6 100644 --- a/tests/components/braviatv/test_diagnostics.py +++ b/tests/components/braviatv/test_diagnostics.py @@ -46,6 +46,7 @@ async def test_entry_diagnostics( config_entry = MockConfigEntry( domain=DOMAIN, + title="BRAVIA TV-Model", data={ CONF_HOST: "localhost", CONF_MAC: "AA:BB:CC:DD:EE:FF", diff --git a/tests/components/bring/test_services.py b/tests/components/bring/test_services.py new file mode 100644 index 00000000000..d010c2b86a0 --- /dev/null +++ b/tests/components/bring/test_services.py @@ -0,0 +1,190 @@ +"""Test actions of Bring! integration.""" + +from unittest.mock import AsyncMock + +from bring_api import ( + ActivityType, + BringActivityResponse, + BringNotificationType, + BringRequestException, + ReactionType, +) +import pytest + +from homeassistant.components.bring.const import ( + ATTR_REACTION, + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("reaction", "call_arg"), + [ + ("drooling", ReactionType.DROOLING), + ("heart", ReactionType.HEART), + ("monocle", ReactionType.MONOCLE), + ("thumbs_up", ReactionType.THUMBS_UP), + ], +) +async def test_send_reaction( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + reaction: str, + call_arg: ReactionType, +) -> None: + """Test send activity stream reaction.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: reaction, + }, + blocking=True, + ) + + mock_bring_client.notify.assert_called_once_with( + "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + BringNotificationType.LIST_ACTIVITY_STREAM_REACTION, + receiver="9a21fdfc-63a4-441a-afc1-ef3030605a9d", + activity="673594a9-f92d-4cb6-adf1-d2f7a83207a4", + activity_type=ActivityType.LIST_ITEMS_CHANGED, + reaction=call_arg, + ) + + +async def test_send_reaction_exception( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test send activity stream reaction with exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + mock_bring_client.notify.side_effect = BringRequestException + with pytest.raises( + HomeAssistantError, + match="Failed to send reaction for Bring! due to a connection error, try again later", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_send_reaction_config_entry_not_loaded( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, +) -> None: + """Test send activity stream reaction config entry not loaded exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(bring_config_entry.entry_id) + + assert bring_config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises( + ServiceValidationError, + match="The account associated with this Bring! list is either not loaded or disabled in Home Assistant", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_send_reaction_unknown_entity( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test send activity stream reaction unknown entity exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + entity_registry.async_update_entity( + "event.einkauf_activities", disabled_by=er.RegistryEntryDisabler.USER + ) + with pytest.raises( + ServiceValidationError, + match="Failed to send reaction for Bring! — Unknown entity event.einkauf_activities", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +async def test_send_reaction_not_found( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test send activity stream reaction not found validation error.""" + mock_bring_client.get_activity.return_value = BringActivityResponse.from_dict( + {"timeline": [], "timestamp": "2025-01-01T03:09:33.036Z", "totalEvents": 0} + ) + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises( + HomeAssistantError, + match="Failed to send reaction for Bring! — No recent activity found", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) diff --git a/tests/components/broadlink/test_climate.py b/tests/components/broadlink/test_climate.py index 6b39d1895b1..fda7fe0cce0 100644 --- a/tests/components/broadlink/test_climate.py +++ b/tests/components/broadlink/test_climate.py @@ -92,7 +92,9 @@ async def test_climate( """Test Broadlink climate.""" device = get_device("Guest room") - mock_setup = await device.setup_entry(hass) + mock_api = device.get_mock_api() + mock_api.get_full_status.return_value = api_return_value + mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_setup.entry.unique_id)} @@ -103,8 +105,6 @@ async def test_climate( climate = climates[0] - mock_setup.api.get_full_status.return_value = api_return_value - await async_update_entity(hass, climate.entity_id) assert mock_setup.api.get_full_status.call_count == 2 state = hass.states.get(climate.entity_id) @@ -122,7 +122,17 @@ async def test_climate_set_temperature_turn_off_turn_on( """Test Broadlink climate.""" device = get_device("Guest room") - mock_setup = await device.setup_entry(hass) + mock_api = device.get_mock_api() + mock_api.get_full_status.return_value = { + "sensor": SensorMode.INNER_SENSOR_CONTROL.value, + "power": 1, + "auto_mode": 0, + "active": 1, + "room_temp": 22, + "thermostat_temp": 23, + "external_temp": 30, + } + mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_setup.entry.unique_id)} diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 91e4338d688..a06131f7216 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -1,19 +1,124 @@ """Tests for the BSBLan device config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock -from bsblan import BSBLANConnectionError +from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError +import pytest -from homeassistant.components.bsblan import config_flow from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry +# ZeroconfServiceInfo fixtures for different discovery scenarios + + +@pytest.fixture +def zeroconf_discovery_info() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device with MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "00:80:41:19:69:90"}, + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_no_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device without MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={}, # No MAC in properties + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_different_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info with a different MAC than the device API returns.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "aa:bb:cc:dd:ee:ff"}, # Different MAC than in device.json + port=80, + hostname="BSB-LAN.local.", + ) + + +# Helper functions to reduce repetition + + +async def _init_user_flow(hass: HomeAssistant, user_input: dict | None = None): + """Initialize a user config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + +async def _init_zeroconf_flow(hass: HomeAssistant, discovery_info): + """Initialize a zeroconf config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + +async def _configure_flow(hass: HomeAssistant, flow_id: str, user_input: dict): + """Configure a flow with user input.""" + return await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input, + ) + + +def _assert_create_entry_result( + result, expected_title: str, expected_data: dict, expected_unique_id: str +): + """Assert that result is a successful CREATE_ENTRY.""" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == expected_title + assert result.get("data") == expected_data + assert "result" in result + assert result["result"].unique_id == expected_unique_id + + +def _assert_form_result( + result, expected_step_id: str, expected_errors: dict | None = None +): + """Assert that result is a FORM with correct step and optional errors.""" + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == expected_step_id + if expected_errors is None: + # Handle both None and {} as valid "no errors" states (like other integrations) + assert result.get("errors") in ({}, None) + else: + assert result.get("errors") == expected_errors + + +def _assert_abort_result(result, expected_reason: str): + """Assert that result is an ABORT with correct reason.""" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + async def test_full_user_flow_implementation( hass: HomeAssistant, @@ -21,17 +126,13 @@ async def test_full_user_flow_implementation( mock_setup_entry: AsyncMock, ) -> None: """Test the full manual user flow from start to finish.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) + result = await _init_user_flow(hass) + _assert_form_result(result, "user") - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - result2 = await hass.config_entries.flow.async_configure( + result = await _configure_flow( + hass, result["flow_id"], - user_input={ + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -40,17 +141,18 @@ async def test_full_user_flow_implementation( }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == format_mac("00:80:41:19:69:90") - assert result2.get("data") == { - CONF_HOST: "127.0.0.1", - CONF_PORT: 80, - CONF_PASSKEY: "1234", - CONF_USERNAME: "admin", - CONF_PASSWORD: "admin1234", - } - assert "result" in result2 - assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") + _assert_create_entry_result( + result, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_bsblan.device.mock_calls) == 1 @@ -58,13 +160,8 @@ async def test_full_user_flow_implementation( async def test_show_user_form(hass: HomeAssistant) -> None: """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM + result = await _init_user_flow(hass) + _assert_form_result(result, "user") async def test_connection_error( @@ -74,10 +171,9 @@ async def test_connection_error( """Test we show user form on BSBLan connection error.""" mock_bsblan.device.side_effect = BSBLANConnectionError - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + result = await _init_user_flow( + hass, + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -86,10 +182,96 @@ async def test_connection_error( }, ) + _assert_form_result(result, "user", {"base": "cannot_connect"}) + + +async def test_authentication_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test we show user form on BSBLan authentication error with field preservation.""" + mock_bsblan.device.side_effect = BSBLANAuthError + + user_input = { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_PASSKEY: "secret", + CONF_USERNAME: "testuser", + CONF_PASSWORD: "wrongpassword", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} + assert result.get("errors") == {"base": "invalid_auth"} assert result.get("step_id") == "user" + # Verify that user input is preserved in the form + data_schema = result.get("data_schema") + assert data_schema is not None + + # Check that the form fields contain the previously entered values + host_field = next( + field for field in data_schema.schema if field.schema == CONF_HOST + ) + port_field = next( + field for field in data_schema.schema if field.schema == CONF_PORT + ) + passkey_field = next( + field for field in data_schema.schema if field.schema == CONF_PASSKEY + ) + username_field = next( + field for field in data_schema.schema if field.schema == CONF_USERNAME + ) + password_field = next( + field for field in data_schema.schema if field.schema == CONF_PASSWORD + ) + + # The defaults are callable functions, so we need to call them + assert host_field.default() == "192.168.1.100" + assert port_field.default() == 8080 + assert passkey_field.default() == "secret" + assert username_field.default() == "testuser" + assert password_field.default() == "wrongpassword" + + +async def test_authentication_error_vs_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test that authentication and connection errors are handled differently.""" + # Test connection error first + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + }, + ) + + _assert_form_result(result, "user", {"base": "cannot_connect"}) + + # Reset and test authentication error + mock_bsblan.device.side_effect = BSBLANAuthError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_USERNAME: "admin", + CONF_PASSWORD: "wrongpass", + }, + ) + + _assert_form_result(result, "user", {"base": "invalid_auth"}) + async def test_user_device_exists_abort( hass: HomeAssistant, @@ -98,10 +280,10 @@ async def test_user_device_exists_abort( ) -> None: """Test we abort flow if BSBLAN device already configured.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + + result = await _init_user_flow( + hass, + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -110,5 +292,770 @@ async def test_user_device_exists_abort( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test the Zeroconf discovery flow.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_abort_if_existing_entry_for_zeroconf( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test we abort if the same host/port already exists during zeroconf discovery.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery_no_mac_requires_auth( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement and device requires auth.""" + # Make the first API call (without auth) fail, second call (with auth) succeed + mock_bsblan.device.side_effect = [ + BSBLANConnectionError, + mock_bsblan.device.return_value, + ] + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_form_result(result, "discovery_confirm") + + # Reset side_effect for the second call to succeed + mock_bsblan.device.side_effect = None + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + ) + + _assert_create_entry_result( + result, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + "00:80:41:19:69:90", + ) + + # Should be called 3 times: once without auth (fails), twice with auth (in _validate_and_create) + assert len(mock_bsblan.device.mock_calls) == 3 + + +async def test_zeroconf_discovery_no_mac_no_auth_required( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement but device accessible without auth.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + + # Should now show the discovery_confirm form to the user + _assert_form_result(result, "discovery_confirm") + + # User confirms the discovery + result = await _configure_flow(hass, result["flow_id"], {}) + + _assert_create_entry_result( + result, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: None, + CONF_PASSWORD: None, + }, + "00:80:41:19:69:90", + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should be called once in zeroconf step, as _validate_and_create is skipped + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_zeroconf_discovery_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery shows the correct form.""" + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"}) + + +async def test_zeroconf_discovery_updates_host_port_on_existing_entry( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test that discovered devices update host/port of existing entries.""" + # Create an existing entry with different host/port + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", # Different IP + CONF_PORT: 8080, # Different port + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port from discovery + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host from discovery + assert entry.data[CONF_PORT] == 80 # Updated port from discovery + + +async def test_user_flow_can_update_existing_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test that manual user configuration can update host/port of existing entries.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Try to configure the same device with different host/port via user flow + result = await _init_user_flow( + hass, + { + CONF_HOST: "10.0.2.60", # Different IP + CONF_PORT: 80, # Different port + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port (user flow behavior) + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host + assert entry.data[CONF_PORT] == 80 # Updated port + + +async def test_zeroconf_discovery_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery can be recovered from.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can recover from BSBLan connection error in user flow.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "user", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_zeroconf_discovery_no_mac_duplicate_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery aborts when no MAC and same host/port already configured.""" + # Create an existing entry with same host/port but no unique_id + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.2.60", # Same IP as discovery + CONF_PORT: 80, # Same port as discovery + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id=None, # Old entry without unique_id + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_abort_result(result, "already_configured") + + # Should not call device API since we abort early + assert len(mock_bsblan.device.mock_calls) == 0 + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reauth flow.""" + mock_config_entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Check that the form has the correct description placeholder + assert result.get("description_placeholders") == {"name": "BSBLAN Setup"} + + # Check that existing values are preserved as defaults + data_schema = result.get("data_schema") + assert data_schema is not None + + # Complete reauth with new credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "new_passkey", + CONF_USERNAME: "new_admin", + CONF_PASSWORD: "new_password", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify config entry was updated with new credentials + assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey" + assert mock_config_entry.data[CONF_USERNAME] == "new_admin" + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + # Verify host and port remain unchanged + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_PORT] == 80 + + +async def test_reauth_flow_auth_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with authentication error.""" + mock_config_entry.add_to_hass(hass) + + # Mock authentication error + mock_bsblan.device.side_effect = BSBLANAuthError + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Submit with wrong credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "wrong_passkey", + CONF_USERNAME: "wrong_admin", + CONF_PASSWORD: "wrong_password", + }, + ) + + _assert_form_result(result, "reauth_confirm", {"base": "invalid_auth"}) + + # Verify that user input is preserved in the form after error + data_schema = result.get("data_schema") + assert data_schema is not None + + # Check that the form fields contain the previously entered values + passkey_field = next( + field for field in data_schema.schema if field.schema == CONF_PASSKEY + ) + username_field = next( + field for field in data_schema.schema if field.schema == CONF_USERNAME + ) + + assert passkey_field.default() == "wrong_passkey" + assert username_field.default() == "wrong_admin" + + +async def test_reauth_flow_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with connection error.""" + mock_config_entry.add_to_hass(hass) + + # Mock connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Submit credentials but get connection error + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "reauth_confirm", {"base": "cannot_connect"}) + + +async def test_reauth_flow_preserves_existing_values( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that reauth flow preserves existing values when user doesn't change them.""" + mock_config_entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Submit without changing any credentials (only password is provided) + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSWORD: "new_password_only", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify that existing passkey and username are preserved + assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original value + assert mock_config_entry.data[CONF_USERNAME] == "admin" # Original value + assert mock_config_entry.data[CONF_PASSWORD] == "new_password_only" # New value + + +async def test_reauth_flow_partial_credentials_update( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with partial credential updates.""" + mock_config_entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + # Submit with only username and password changes + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_USERNAME: "new_admin", + CONF_PASSWORD: "new_password", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify partial update: passkey preserved, username and password updated + assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original preserved + assert mock_config_entry.data[CONF_USERNAME] == "new_admin" # Updated + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" # Updated + # Host and port should remain unchanged + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_PORT] == 80 + + +async def test_reauth_flow_preserves_non_credential_fields( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test reauth flow preserves non-credential fields using data_updates.""" + # Create a config entry with additional custom fields that should be preserved + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "old_key", + CONF_USERNAME: "old_user", + CONF_PASSWORD: "old_pass", + # Add some custom fields that should be preserved + "custom_field": "should_be_preserved", + "another_field": 42, + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + # Submit with only new credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "new_key", + CONF_USERNAME: "new_user", + CONF_PASSWORD: "new_pass", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify that only the provided fields were updated, others preserved + assert entry.data[CONF_PASSKEY] == "new_key" # Updated + assert entry.data[CONF_USERNAME] == "new_user" # Updated + assert entry.data[CONF_PASSWORD] == "new_pass" # Updated + + # These fields should remain unchanged (preserved by data_updates) + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_PORT] == 80 + assert entry.data["custom_field"] == "should_be_preserved" + assert entry.data["another_field"] == 42 + + +async def test_reauth_flow_clears_credentials_with_empty_strings( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test reauth flow can clear credentials by providing empty strings.""" + # Create a config entry with existing credentials + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "existing_key", + CONF_USERNAME: "existing_user", + CONF_PASSWORD: "existing_pass", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + # Submit with empty strings to clear credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "", # Clear passkey + CONF_USERNAME: "", # Clear username + CONF_PASSWORD: "", # Clear password + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify that credentials were cleared (set to empty strings) + assert entry.data[CONF_PASSKEY] == "" + assert entry.data[CONF_USERNAME] == "" + assert entry.data[CONF_PASSWORD] == "" + + # Host and port should remain unchanged + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_PORT] == 80 + + +async def test_reauth_flow_partial_clear_credentials( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test reauth flow can partially clear some credentials while updating others.""" + # Create a config entry with existing credentials + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "existing_key", + CONF_USERNAME: "existing_user", + CONF_PASSWORD: "existing_pass", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + # Submit with mix of clearing and updating credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "", # Clear passkey + CONF_USERNAME: "new_user", # Update username + CONF_PASSWORD: "", # Clear password + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify mixed update: some cleared, some updated, some preserved + assert entry.data[CONF_PASSKEY] == "" # Cleared + assert entry.data[CONF_USERNAME] == "new_user" # Updated + assert entry.data[CONF_PASSWORD] == "" # Cleared + + # Host and port should remain unchanged + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_PORT] == 80 + + +async def test_zeroconf_discovery_auth_error_during_confirm( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test authentication error during discovery_confirm step.""" + # Remove MAC from discovery to force discovery_confirm step + zeroconf_discovery_info.properties.pop("mac", None) + + # Setup device to require authentication during initial discovery + mock_bsblan.device.side_effect = BSBLANError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_info, + ) + + _assert_form_result(result, "discovery_confirm") + + # Now setup auth error for the confirmation step + mock_bsblan.device.side_effect = BSBLANAuthError + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "wrong_key", + CONF_USERNAME: "admin", + CONF_PASSWORD: "wrong_password", + }, + ) + + # Should show the discovery_confirm form again with auth error + _assert_form_result(result, "discovery_confirm", {"base": "invalid_auth"}) diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index a9c3605f67f..10945a24878 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -2,13 +2,15 @@ from unittest.mock import MagicMock -from bsblan import BSBLANConnectionError +from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError +from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.bsblan.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_load_unload_config_entry( @@ -45,3 +47,67 @@ async def test_config_entry_not_ready( assert len(mock_bsblan.state.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_auth_failed_triggers_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that BSBLANAuthError during coordinator update triggers reauth flow.""" + # First, set up the integration successfully + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Mock BSBLANAuthError during next update + mock_bsblan.initialize.side_effect = BSBLANAuthError("Authentication failed") + + # Advance time by the coordinator's update interval to trigger update + freezer.tick(delta=20) # Advance beyond the 12 second scan interval + random offset + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that a reauth flow has been started + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("method", "exception", "expected_state"), + [ + ( + "device", + BSBLANConnectionError("Connection failed"), + ConfigEntryState.SETUP_RETRY, + ), + ( + "info", + BSBLANAuthError("Authentication failed"), + ConfigEntryState.SETUP_ERROR, + ), + ("static_values", BSBLANError("General error"), ConfigEntryState.SETUP_ERROR), + ], +) +async def test_config_entry_static_data_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + method: str, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test various errors during static data fetching trigger appropriate config entry states.""" + # Mock the specified method to raise the exception + getattr(mock_bsblan, method).side_effect = exception + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index f1cffa8583f..63fdece9c98 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -253,14 +253,14 @@ _LOGGER = logging.getLogger(__name__) { "sensor_entity": "sensor.test_device_18b2_pm10", "friendly_name": "Test Device 18B2 Pm10", - "unit_of_measurement": "µg/m³", + "unit_of_measurement": "μg/m³", "state_class": "measurement", "expected_state": "7170", }, { "sensor_entity": "sensor.test_device_18b2_pm25", "friendly_name": "Test Device 18B2 Pm25", - "unit_of_measurement": "µg/m³", + "unit_of_measurement": "μg/m³", "state_class": "measurement", "expected_state": "3090", }, @@ -296,7 +296,7 @@ _LOGGER = logging.getLogger(__name__) "sensor.test_device_18b2_volatile_organic_compounds" ), "friendly_name": "Test Device 18B2 Volatile Organic Compounds", - "unit_of_measurement": "µg/m³", + "unit_of_measurement": "μg/m³", "state_class": "measurement", "expected_state": "307", }, @@ -607,14 +607,14 @@ async def test_v1_sensors( { "sensor_entity": "sensor.test_device_18b2_pm10", "friendly_name": "Test Device 18B2 Pm10", - "unit_of_measurement": "µg/m³", + "unit_of_measurement": "μg/m³", "state_class": "measurement", "expected_state": "7170", }, { "sensor_entity": "sensor.test_device_18b2_pm25", "friendly_name": "Test Device 18B2 Pm25", - "unit_of_measurement": "µg/m³", + "unit_of_measurement": "μg/m³", "state_class": "measurement", "expected_state": "3090", }, @@ -650,7 +650,7 @@ async def test_v1_sensors( "sensor.test_device_18b2_volatile_organic_compounds" ), "friendly_name": "Test Device 18B2 Volatile Organic Compounds", - "unit_of_measurement": "µg/m³", + "unit_of_measurement": "μg/m³", "state_class": "measurement", "expected_state": "307", }, diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 7f4bbed36f7..22642635375 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '0020c2d8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Cambridge Audio', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0020c2d8', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index 10e9311c4b0..7bdc2dddc8d 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -45,7 +45,6 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, - STATE_STANDBY, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -156,8 +155,8 @@ async def test_entity_supported_features_with_control_bus( @pytest.mark.parametrize( ("power_state", "play_state", "media_player_state"), [ - (True, "NETWORK", STATE_STANDBY), - (False, "NETWORK", STATE_STANDBY), + (True, "NETWORK", STATE_OFF), + (False, "NETWORK", STATE_OFF), (False, "play", STATE_OFF), (True, "play", STATE_PLAYING), (True, "pause", STATE_PAUSED), diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index 4b5a578ecc4..06072b88afe 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -354,7 +354,12 @@ async def test_get_trigger_capabilities_hvac_mode(hass: HomeAssistant) -> None: "required": True, "type": "select", }, - {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + }, ] @@ -389,13 +394,20 @@ async def test_get_trigger_capabilities_temp_humid( "description": {"suffix": suffix}, "name": "above", "optional": True, + "required": False, "type": "float", }, { "description": {"suffix": suffix}, "name": "below", "optional": True, + "required": False, "type": "float", }, - {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + }, ] diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 0e118f251de..10d38c227f1 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any from unittest.mock import DEFAULT, AsyncMock, MagicMock, PropertyMock, patch -from hass_nabucasa import Cloud +from hass_nabucasa import Cloud, payments_api from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED @@ -55,7 +55,10 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: # Attributes set in the constructor without parameters. # We spec the mocks with the real classes # and set constructor attributes or mock properties as needed. - mock_cloud.google_report_state = MagicMock(spec=GoogleReportState) + mock_cloud.google_report_state = MagicMock( + spec=GoogleReportState, + request_sync=AsyncMock(), + ) mock_cloud.cloudhooks = MagicMock(spec=Cloudhooks) mock_cloud.remote = MagicMock( spec=RemoteUI, @@ -71,6 +74,11 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: mock_cloud.voice = MagicMock(spec=Voice) mock_cloud.files = MagicMock(spec=Files) mock_cloud.started = None + mock_cloud.payments = MagicMock( + spec=payments_api.PaymentsApi, + subscription_info=AsyncMock(), + migrate_paypal_agreement=AsyncMock(), + ) mock_cloud.ice_servers = MagicMock( spec=IceServers, async_register_ice_servers_listener=AsyncMock( diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index c67691dfa1a..52c544dc541 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -37,7 +37,7 @@ google_enabled | False cloud_ice_servers_enabled | True remote_server | us-west-1 - certificate_status | CertificateStatus.READY + certificate_status | ready instance_id | 12345678901234567890 can_reach_cert_server | Exception: Unexpected exception can_reach_cloud_auth | Failed: unreachable diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index ef7a99453f0..7fc8c73785b 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -3,6 +3,11 @@ import contextlib from unittest.mock import AsyncMock, Mock, patch +from hass_nabucasa.alexa_api import ( + AlexaApiError, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) import pytest from homeassistant.components.alexa import errors @@ -195,30 +200,40 @@ async def test_alexa_config_invalidate_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 assert conf._token_valid is not None conf.async_invalidate_access_token() assert conf._token_valid is None token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 2 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 2 @pytest.mark.parametrize( - ("reject_reason", "expected_exception"), + ("lib_exception", "expected_exception"), [ - ("RefreshTokenNotFound", errors.RequireRelink), - ("UnknownRegion", errors.RequireRelink), - ("OtherReason", errors.NoTokenAvailable), + (AlexaApiNeedsRelinkError("Needs relink"), errors.RequireRelink), + (AlexaApiNeedsRelinkError("UnknownRegion"), errors.RequireRelink), + (AlexaApiNoTokenError("OtherReason"), errors.NoTokenAvailable), + (AlexaApiError("OtherReason"), errors.NoTokenAvailable), ], ) async def test_alexa_config_fail_refresh_token( @@ -226,7 +241,7 @@ async def test_alexa_config_fail_refresh_token( cloud_prefs: CloudPreferences, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, - reject_reason: str, + lib_exception: Exception, expected_exception: type[Exception], ) -> None: """Test Alexa config failing to refresh token.""" @@ -259,6 +274,15 @@ async def test_alexa_config_fail_refresh_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) await conf.async_initialize() @@ -284,12 +308,7 @@ async def test_alexa_config_fail_refresh_token( # Invalidate the token and try to fetch another conf.async_invalidate_access_token() - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={"reason": reject_reason}, - status=400, - ) + conf._cloud.alexa_api.access_token.side_effect = lib_exception # Change states to trigger event listener hass.states.async_set(entity_entry.entity_id, "off") @@ -310,15 +329,8 @@ async def test_alexa_config_fail_refresh_token( # Simulate we're again authorized and token update succeeds # State reporting should now be re-enabled for Alexa - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={ - "access_token": "mock-token", - "event_endpoint": "http://example.com/alexa_endpoint", - "expires_in": 30, - }, - ) + conf._cloud.alexa_api.access_token.side_effect = None + await conf.set_authorized(True) assert cloud_prefs.alexa_report_state is True assert conf.should_report_state is True diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index c9e0f37829a..df46102d03d 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any -from unittest.mock import ANY, Mock, PropertyMock, patch +from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch from aiohttp import ClientError, ClientResponseError from hass_nabucasa import CloudError @@ -21,7 +21,6 @@ from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.backup import async_register_backup_agents_listener from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReaderChunked @@ -37,8 +36,7 @@ async def setup_integration( cloud: MagicMock, cloud_logged_in: None, ) -> AsyncGenerator[None]: - """Set up cloud and backup integrations.""" - async_initialize_backup(hass) + """Set up cloud integration.""" with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), @@ -50,62 +48,56 @@ async def setup_integration( @pytest.fixture -def mock_delete_file() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_delete_file", - spec_set=True, - ) as delete_file: - yield delete_file +def mock_delete_file(cloud: MagicMock) -> Generator[AsyncMock]: + """Mock delete files.""" + cloud.files.delete = AsyncMock() + return cloud.files.delete @pytest.fixture -def mock_list_files() -> Generator[MagicMock]: +def mock_list_files(cloud: MagicMock) -> Generator[MagicMock]: """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_list", spec_set=True - ) as list_files: - list_files.return_value = [ - { - "Key": "462e16810d6841228828d9dd2f9e341e.tar", - "LastModified": "2024-11-22T10:49:01.182Z", - "Size": 34519040, - "Metadata": { - "addons": [], - "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", - "database_included": True, - "extra_metadata": {}, - "folders": [], - "homeassistant_included": True, - "homeassistant_version": "2024.12.0.dev0", - "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "storage-type": "backup", - }, + cloud.files.list.return_value = [ + { + "Key": "462e16810d6841228828d9dd2f9e341e.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", }, - { - "Key": "462e16810d6841228828d9dd2f9e341f.tar", - "LastModified": "2024-11-22T10:49:01.182Z", - "Size": 34519040, - "Metadata": { - "addons": [], - "backup_id": "23e64aed", - "date": "2024-11-22T11:48:48.727189+01:00", - "database_included": True, - "extra_metadata": {}, - "folders": [], - "homeassistant_included": True, - "homeassistant_version": "2024.12.0.dev0", - "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "storage-type": "backup", - }, + }, + { + "Key": "462e16810d6841228828d9dd2f9e341f.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aed", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", }, - ] - yield list_files + }, + ] + return cloud.files.list @pytest.fixture @@ -143,7 +135,7 @@ async def test_agents_list_backups( client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/info"}) response = await client.receive_json() - mock_list_files.assert_called_once_with(cloud, storage_type="backup") + mock_list_files.assert_called_once_with(storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} @@ -252,7 +244,7 @@ async def test_agents_get_backup( client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) response = await client.receive_json() - mock_list_files.assert_called_once_with(cloud, storage_type="backup") + mock_list_files.assert_called_once_with(storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} @@ -728,7 +720,6 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} mock_delete_file.assert_called_once_with( - cloud, filename="462e16810d6841228828d9dd2f9e341e.tar", storage_type=StorageType.BACKUP, ) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index cb456be5036..e6fb289d09e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -1,7 +1,7 @@ """Test the Cloud Google Config.""" from http import HTTPStatus -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch from freezegun import freeze_time import pytest @@ -119,15 +119,13 @@ async def test_sync_entities( assert len(mock_conf.async_get_agent_users()) == 1 - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=HTTPStatus.NOT_FOUND), - ) as mock_request_sync: - assert ( - await mock_conf.async_sync_entities("mock-user-id") == HTTPStatus.NOT_FOUND - ) - assert len(mock_conf.async_get_agent_users()) == 0 - assert len(mock_request_sync.mock_calls) == 1 + mock_conf._cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.NOT_FOUND) + ) + + assert await mock_conf.async_sync_entities("mock-user-id") == HTTPStatus.NOT_FOUND + assert len(mock_conf.async_get_agent_users()) == 0 + assert len(mock_conf._cloud.google_report_state.request_sync.mock_calls) == 1 async def test_google_update_expose_trigger_sync( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 79764e552c7..96927477b0a 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -18,6 +18,7 @@ from hass_nabucasa.auth import ( UnknownError, ) from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.payments_api import PaymentsApiError from hass_nabucasa.remote import CertificateStatus import pytest from syrupy.assertion import SnapshotAssertion @@ -138,31 +139,34 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: async def test_google_actions_sync( setup_cloud: None, hass_client: ClientSessionGenerator, + cloud: MagicMock, ) -> None: """Test syncing Google Actions.""" cloud_client = await hass_client() - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=200), - ) as mock_request_sync: - req = await cloud_client.post("/api/cloud/google_actions/sync") - assert req.status == HTTPStatus.OK - assert mock_request_sync.call_count == 1 + + cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.OK) + ) + + req = await cloud_client.post("/api/cloud/google_actions/sync") + assert req.status == HTTPStatus.OK + assert len(cloud.google_report_state.request_sync.mock_calls) == 1 async def test_google_actions_sync_fails( setup_cloud: None, hass_client: ClientSessionGenerator, + cloud: MagicMock, ) -> None: """Test syncing Google Actions gone bad.""" cloud_client = await hass_client() - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR), - ) as mock_request_sync: - req = await cloud_client.post("/api/cloud/google_actions/sync") - assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert mock_request_sync.call_count == 1 + cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR) + ) + + req = await cloud_client.post("/api/cloud/google_actions/sync") + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert len(cloud.google_report_state.request_sync.mock_calls) == 1 @pytest.mark.parametrize( @@ -1008,16 +1012,14 @@ async def test_websocket_subscription_info( cloud: MagicMock, setup_cloud: None, ) -> None: - """Test subscription info and connecting because valid account.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) + """Test subscription info.""" + cloud.payments.subscription_info.return_value = {"provider": "stripe"} client = await hass_ws_client(hass) - mock_renew = cloud.auth.async_renew_access_token await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() assert response["result"] == {"provider": "stripe"} - assert mock_renew.call_count == 1 async def test_websocket_subscription_fail( @@ -1028,7 +1030,9 @@ async def test_websocket_subscription_fail( setup_cloud: None, ) -> None: """Test subscription info fail.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=HTTPStatus.INTERNAL_SERVER_ERROR) + cloud.payments.subscription_info.side_effect = PaymentsApiError( + "Failed to fetch subscription information" + ) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "cloud/subscription"}) @@ -1049,7 +1053,7 @@ async def test_websocket_subscription_not_logged_in( client = await hass_ws_client(hass) with patch( - "hass_nabucasa.cloud_api.async_subscription_info", + "hass_nabucasa.payments_api.PaymentsApi.subscription_info", return_value={"return": "value"}, ): await client.send_json({"id": 5, "type": "cloud/subscription"}) diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d131d211e2f..0377ee81dba 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -4,6 +4,7 @@ from datetime import timedelta from http import HTTPStatus from unittest.mock import patch +from hass_nabucasa.payments_api import PaymentsApiError import pytest from homeassistant.components.cloud.const import DOMAIN @@ -210,7 +211,13 @@ async def test_legacy_subscription_repair_flow_timeout( "preview": None, } - with patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0): + with ( + patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0), + patch( + "hass_nabucasa.payments_api.PaymentsApi.migrate_paypal_agreement", + side_effect=PaymentsApiError("some error", status=403), + ), + ): resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") assert resp.status == HTTPStatus.OK data = await resp.json() @@ -236,7 +243,6 @@ async def test_legacy_subscription_repair_flow_timeout( "handler": "cloud", "reason": "operation_took_too_long", "description_placeholders": None, - "result": None, } assert issue_registry.async_get_issue( diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py index 22839b585fd..ba45e6bca57 100644 --- a/tests/components/cloud/test_subscription.py +++ b/tests/components/cloud/test_subscription.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, Mock -from hass_nabucasa import Cloud +from hass_nabucasa import Cloud, payments_api import pytest from homeassistant.components.cloud.subscription import ( @@ -22,6 +22,11 @@ async def mocked_cloud_object(hass: HomeAssistant) -> Cloud: accounts_server="accounts.nabucasa.com", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + payments=Mock( + spec=payments_api.PaymentsApi, + subscription_info=AsyncMock(), + migrate_paypal_agreement=AsyncMock(), + ), ) @@ -31,14 +36,13 @@ async def test_fetching_subscription_with_timeout_error( mocked_cloud: Cloud, ) -> None: """Test that we handle timeout error.""" - aioclient_mock.get( - "https://accounts.nabucasa.com/payments/subscription_info", - exc=TimeoutError(), + mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError( + "Timeout reached while calling API" ) assert await async_subscription_info(mocked_cloud) is None assert ( - "A timeout of 10 was reached while trying to fetch subscription information" + "Failed to fetch subscription information - Timeout reached while calling API" in caplog.text ) @@ -49,10 +53,7 @@ async def test_migrate_paypal_agreement_with_timeout_error( mocked_cloud: Cloud, ) -> None: """Test that we handle timeout error.""" - aioclient_mock.post( - "https://accounts.nabucasa.com/payments/migrate_paypal_agreement", - exc=TimeoutError(), - ) + mocked_cloud.payments.migrate_paypal_agreement.side_effect = TimeoutError() assert await async_migrate_paypal_agreement(mocked_cloud) is None assert ( diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index c920fdac264..44430f9c39a 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,10 +1,12 @@ """Tests for cloud tts.""" -from collections.abc import AsyncGenerator, Callable, Coroutine +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Coroutine from copy import deepcopy from http import HTTPStatus +import io from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import wave from hass_nabucasa.voice import VoiceError, VoiceTokenError from hass_nabucasa.voice_data import TTS_VOICES @@ -239,6 +241,12 @@ async def test_get_tts_audio( side_effect=mock_process_tts_side_effect, ) cloud.voice.process_tts = mock_process_tts + + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + if mock_process_tts_side_effect: + mock_process_tts_stream.side_effect = mock_process_tts_side_effect + cloud.voice.process_tts_stream = mock_process_tts_stream + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -262,13 +270,27 @@ async def test_get_tts_audio( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == "en-US" + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == "JennyNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" @pytest.mark.parametrize( @@ -321,10 +343,10 @@ async def test_get_tts_audio_logged_out( @pytest.mark.parametrize( - ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + ("mock_process_tts_side_effect"), [ - (b"", None), - (None, VoiceError("Boom!")), + (None,), + (VoiceError("Boom!"),), ], ) async def test_tts_entity( @@ -332,15 +354,13 @@ async def test_tts_entity( hass_client: ClientSessionGenerator, entity_registry: EntityRegistry, cloud: MagicMock, - mock_process_tts_return_value: bytes | None, mock_process_tts_side_effect: Exception | None, ) -> None: """Test text-to-speech entity.""" - mock_process_tts = AsyncMock( - return_value=mock_process_tts_return_value, - side_effect=mock_process_tts_side_effect, - ) - cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + if mock_process_tts_side_effect: + mock_process_tts_stream.side_effect = mock_process_tts_side_effect + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -372,13 +392,14 @@ async def test_tts_entity( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == "en-US" + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == "JennyNeural" state = hass.states.get(entity_id) assert state @@ -482,6 +503,8 @@ async def test_deprecated_voice( return_value=b"", ) cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -509,18 +532,34 @@ async def test_deprecated_voice( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == replacement_voice + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue( "cloud", f"deprecated_voice_{replacement_voice}" ) assert issue is None mock_process_tts.reset_mock() + mock_process_tts_stream.reset_mock() # Test with deprecated voice. data["options"] = {"voice": deprecated_voice} @@ -538,15 +577,30 @@ async def test_deprecated_voice( } await hass.async_block_till_done() + # Force streaming + await client.get(response["path"]) + issue_id = f"deprecated_voice_{deprecated_voice}" - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == replacement_voice + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", issue_id) assert issue is not None assert issue.breaks_in_ha_version == "2024.8.0" @@ -623,6 +677,8 @@ async def test_deprecated_gender( return_value=b"", ) cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -649,15 +705,30 @@ async def test_deprecated_gender( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["voice"] == "XiaoxiaoNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", "deprecated_gender") assert issue is None mock_process_tts.reset_mock() + mock_process_tts_stream.reset_mock() # Test with deprecated gender option. data["options"] = {"gender": gender_option} @@ -675,15 +746,30 @@ async def test_deprecated_gender( } await hass.async_block_till_done() + # Force streaming + await client.get(response["path"]) + issue_id = "deprecated_gender" - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] == gender_option - assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] == gender_option + assert mock_process_tts_stream.call_args.kwargs["voice"] == "XiaoxiaoNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] == gender_option + assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", issue_id) assert issue is not None assert issue.breaks_in_ha_version == "2024.10.0" @@ -772,6 +858,8 @@ async def test_tts_services( calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) mock_process_tts = AsyncMock(return_value=b"") cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -793,9 +881,51 @@ async def test_tts_services( assert response.status == HTTPStatus.OK await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == service_data[ATTR_LANGUAGE] - assert mock_process_tts.call_args.kwargs["voice"] == "GadisNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if service_data.get("entity_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert ( + mock_process_tts_stream.call_args.kwargs["language"] + == service_data[ATTR_LANGUAGE] + ) + assert mock_process_tts_stream.call_args.kwargs["voice"] == "GadisNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert ( + mock_process_tts.call_args.kwargs["language"] == service_data[ATTR_LANGUAGE] + ) + assert mock_process_tts.call_args.kwargs["voice"] == "GadisNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + + +def _make_stream_mock(expected_text: str) -> MagicMock: + """Create a mock TTS stream generator with just a WAV header.""" + with io.BytesIO() as wav_io: + wav_writer: wave.Wave_write = wave.open(wav_io, "wb") + with wav_writer: + wav_writer.setframerate(24000) + wav_writer.setsampwidth(2) + wav_writer.setnchannels(1) + + wav_io.seek(0) + wav_bytes = wav_io.getvalue() + + process_tts_stream = MagicMock() + + async def fake_process_tts_stream(*, text_stream: AsyncIterable[str], **kwargs): + # Verify text + actual_text = "".join([text_chunk async for text_chunk in text_stream]) + assert actual_text == expected_text + + # WAV header + yield wav_bytes + + process_tts_stream.side_effect = fake_process_tts_stream + + return process_tts_stream diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 0a2475ac218..be538c7a42d 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -5,7 +5,7 @@ from homeassistant.components.coinbase.const import ( CONF_EXCHANGE_RATES, DOMAIN, ) -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant from .const import ( @@ -65,7 +65,7 @@ class MockGetAccountsV3: start = ids.index(cursor) if cursor else 0 has_next = (target_end := start + 2) < len(MOCK_ACCOUNTS_RESPONSE_V3) - end = target_end if has_next else -1 + end = target_end if has_next else len(MOCK_ACCOUNTS_RESPONSE_V3) next_cursor = ids[end] if has_next else ids[-1] self.accounts = { "accounts": MOCK_ACCOUNTS_RESPONSE_V3[start:end], @@ -120,31 +120,6 @@ async def init_mock_coinbase( hass: HomeAssistant, currencies: list[str] | None = None, rates: list[str] | None = None, -) -> MockConfigEntry: - """Init Coinbase integration for testing.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="080272b77a4f80c41b94d7cdc86fd826", - unique_id=None, - title="Test User", - data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, - options={ - CONF_CURRENCIES: currencies or [], - CONF_EXCHANGE_RATES: rates or [], - }, - ) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry - - -async def init_mock_coinbase_v3( - hass: HomeAssistant, - currencies: list[str] | None = None, - rates: list[str] | None = None, ) -> MockConfigEntry: """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( @@ -155,7 +130,6 @@ async def init_mock_coinbase_v3( data={ CONF_API_KEY: "organizations/123456", CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v3", }, options={ CONF_CURRENCIES: currencies or [], diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index aa2c6208e0f..3858df83269 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -3,9 +3,8 @@ import logging from unittest.mock import patch -from coinbase.wallet.error import AuthenticationError +from coinbase.rest.rest_base import HTTPError import pytest -from requests.models import Response from homeassistant import config_entries from homeassistant.components.coinbase.const import ( @@ -14,17 +13,14 @@ from homeassistant.components.coinbase.const import ( CONF_EXCHANGE_RATES, DOMAIN, ) -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .common import ( init_mock_coinbase, - init_mock_coinbase_v3, - mock_get_current_user, mock_get_exchange_rates, mock_get_portfolios, - mocked_get_accounts, mocked_get_accounts_v3, ) from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE @@ -41,13 +37,13 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), patch( "homeassistant.components.coinbase.async_setup_entry", @@ -61,11 +57,10 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test User" + assert result2["title"] == "Default" assert result2["data"] == { CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v2", } assert len(mock_setup_entry.mock_calls) == 1 @@ -80,16 +75,9 @@ async def test_form_invalid_auth( caplog.set_level(logging.DEBUG) - response = Response() - response.status_code = 401 - api_auth_error_unknown = AuthenticationError( - response, - "authentication_error", - "unknown error", - [{"id": "authentication_error", "message": "unknown error"}], - ) + api_auth_error_unknown = HTTPError("unknown error") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_unknown, ): result2 = await hass.config_entries.flow.async_configure( @@ -104,14 +92,9 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth"} assert "Coinbase rejected API credentials due to an unknown error" in caplog.text - api_auth_error_key = AuthenticationError( - response, - "authentication_error", - "invalid api key", - [{"id": "authentication_error", "message": "invalid api key"}], - ) + api_auth_error_key = HTTPError("invalid api key") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_key, ): result2 = await hass.config_entries.flow.async_configure( @@ -126,14 +109,9 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth_key"} assert "Coinbase rejected API credentials due to an invalid API key" in caplog.text - api_auth_error_secret = AuthenticationError( - response, - "authentication_error", - "invalid signature", - [{"id": "authentication_error", "message": "invalid signature"}], - ) + api_auth_error_secret = HTTPError("invalid signature") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_secret, ): result2 = await hass.config_entries.flow.async_configure( @@ -158,7 +136,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=ConnectionError, ): result2 = await hass.config_entries.flow.async_configure( @@ -180,7 +158,7 @@ async def test_form_catch_all_exception(hass: HomeAssistant) -> None: ) with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=Exception, ): result2 = await hass.config_entries.flow.async_configure( @@ -200,17 +178,14 @@ async def test_option_form(hass: HomeAssistant) -> None: with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), - patch( - "homeassistant.components.coinbase.update_listener" - ) as mock_update_listener, ): config_entry = await init_mock_coinbase(hass) await hass.async_block_till_done() @@ -226,20 +201,19 @@ async def test_option_form(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - assert len(mock_update_listener.mock_calls) == 1 async def test_form_bad_account_currency(hass: HomeAssistant) -> None: """Test we handle a bad currency option.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -262,13 +236,13 @@ async def test_form_bad_exchange_rate(hass: HomeAssistant) -> None: """Test we handle a bad exchange rate.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -290,13 +264,13 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: """Test we handle an unknown exception in the option flow.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -304,7 +278,7 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: await hass.async_block_till_done() with patch( - "coinbase.wallet.client.Client.get_accounts", + "coinbase.rest.RESTClient.get_accounts", side_effect=Exception, ): result2 = await hass.config_entries.options.async_configure( @@ -320,75 +294,99 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_form_v3(hass: HomeAssistant) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth flow.""" with ( - patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( "coinbase.rest.RESTClient.get_portfolios", return_value=mock_get_portfolios(), ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.rest.RESTBase.get", + "coinbase.rest.RESTClient.get", return_value={"data": mock_get_exchange_rates()}, ), + ): + config_entry = await init_mock_coinbase(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Test successful reauth + with ( patch( - "homeassistant.components.coinbase.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), + ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), + patch( + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_KEY: "organizations/123456", CONF_API_TOKEN: "AbCDeF"}, + { + CONF_API_KEY: "new_key", + CONF_API_TOKEN: "new_secret", + }, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Default" - assert result2["data"] == { - CONF_API_KEY: "organizations/123456", - CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v3", - } - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "new_key" + assert config_entry.data[CONF_API_TOKEN] == "new_secret" -async def test_option_form_v3(hass: HomeAssistant) -> None: - """Test we handle a good wallet currency option.""" - +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test reauth flow with invalid credentials.""" with ( - patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( "coinbase.rest.RESTClient.get_portfolios", return_value=mock_get_portfolios(), ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.rest.RESTBase.get", + "coinbase.rest.RESTClient.get", return_value={"data": mock_get_exchange_rates()}, ), - patch( - "homeassistant.components.coinbase.update_listener" - ) as mock_update_listener, ): - config_entry = await init_mock_coinbase_v3(hass) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() - result2 = await hass.config_entries.options.async_configure( + config_entry = await init_mock_coinbase(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + # Test invalid auth during reauth + api_auth_error_key = HTTPError("invalid api key") + with patch( + "coinbase.rest.RESTClient.get_portfolios", + side_effect=api_auth_error_key, + ): + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], - CONF_EXCHANGE_PRECISION: 5, + { + CONF_API_KEY: "bad_key", + CONF_API_TOKEN: "bad_secret", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - assert len(mock_update_listener.mock_calls) == 1 + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {"base": "invalid_auth_key"} diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index 98936f47e48..5e708756d80 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from .common import ( init_mock_coinbase, - mock_get_current_user, mock_get_exchange_rates, - mocked_get_accounts, + mock_get_portfolios, + mocked_get_accounts_v3, ) from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -27,13 +27,13 @@ async def test_entry_diagnostics( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 99b6bb4a9bd..7705a4d8e81 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -2,6 +2,9 @@ from unittest.mock import patch +import pytest + +from homeassistant.components.coinbase import create_and_update_instance from homeassistant.components.coinbase.const import ( API_TYPE_VAULT, CONF_CURRENCIES, @@ -9,14 +12,16 @@ from homeassistant.components.coinbase.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from .common import ( init_mock_coinbase, - mock_get_current_user, mock_get_exchange_rates, - mocked_get_accounts, + mock_get_portfolios, + mocked_get_accounts_v3, ) from .const import ( GOOD_CURRENCY, @@ -30,16 +35,16 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), patch( - "coinbase.wallet.client.Client.get_accounts", - new=mocked_get_accounts, + "coinbase.rest.RESTClient.get_accounts", + new=mocked_get_accounts_v3, ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": {"rates": {}}}, ), ): entry = await init_mock_coinbase(hass) @@ -61,13 +66,13 @@ async def test_option_updates( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -141,13 +146,13 @@ async def test_ignore_vaults_wallets( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) @@ -159,3 +164,54 @@ async def test_ignore_vaults_wallets( assert len(entities) == 1 entity = entities[0] assert API_TYPE_VAULT not in entity.original_name.lower() + + +async def test_v2_api_credentials_trigger_reauth(hass: HomeAssistant) -> None: + """Test that v2 API credentials trigger a reauth flow.""" + + config_entry_data = { + CONF_API_KEY: "v2_api_key_legacy_format", + CONF_API_TOKEN: "v2_api_secret", + } + + class MockConfigEntry: + def __init__(self, data) -> None: + self.data = data + self.options = {} + + entry = MockConfigEntry(config_entry_data) + + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + create_and_update_instance(entry) + + assert "deprecated v2 API" in str(exc_info.value) + + +async def test_v3_api_credentials_work(hass: HomeAssistant) -> None: + """Test that v3 API credentials with 'organizations' don't trigger reauth.""" + + config_entry_data = { + CONF_API_KEY: "organizations_v3_api_key", + CONF_API_TOKEN: "v3_api_secret", + } + + class MockConfigEntry: + def __init__(self, data) -> None: + self.data = data + self.options = {} + + entry = MockConfigEntry(config_entry_data) + + with ( + patch( + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), + ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), + patch( + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, + ), + ): + instance = create_and_update_instance(entry) + assert instance is not None diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 877a4f972a9..182db0de54f 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -1,174 +1,232 @@ """The tests for the integration sensor platform.""" +from typing import Any +from unittest.mock import patch + import pytest +from homeassistant import config as hass_config from homeassistant.components.compensation.const import CONF_PRECISION, DOMAIN from homeassistant.components.compensation.sensor import ATTR_COEFFICIENTS -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, EVENT_HOMEASSISTANT_START, EVENT_STATE_CHANGED, + SERVICE_RELOAD, + STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, get_fixture_path -async def test_linear_state(hass: HomeAssistant) -> None: +TEST_OBJECT_ID = "test_compensation" +TEST_ENTITY_ID = "sensor.test_compensation" +TEST_SOURCE = "sensor.uncompensated" + +TEST_BASE_CONFIG = { + "source": TEST_SOURCE, + "data_points": [ + [1.0, 2.0], + [2.0, 3.0], + ], + "precision": 2, +} +TEST_CONFIG = { + "name": TEST_OBJECT_ID, + "unit_of_measurement": "a", + **TEST_BASE_CONFIG, +} + + +async def async_setup_compensation(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Do setup of a compensation integration sensor.""" + with assert_setup_component(1, DOMAIN): + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test": config}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_compensation(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Do setup of a compensation integration sensor.""" + await async_setup_compensation(hass, config) + + +@pytest.fixture +async def setup_compensation_with_limits( + hass: HomeAssistant, + config: dict[str, Any], + upper: bool, + lower: bool, +): + """Do setup of a compensation integration sensor with extra config.""" + await async_setup_compensation( + hass, + { + **config, + "lower_limit": lower, + "upper_limit": upper, + }, + ) + + +@pytest.fixture +async def caplog_setup_text(caplog: pytest.LogCaptureFixture) -> str: + """Return setup log of integration.""" + return caplog.text + + +@pytest.mark.parametrize("config", [TEST_CONFIG]) +@pytest.mark.usefixtures("setup_compensation") +async def test_linear_state(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test compensation sensor state.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "data_points": [ - [1.0, 2.0], - [2.0, 3.0], - ], - "precision": 2, - "unit_of_measurement": "a", - } - } - } - expected_entity_id = "sensor.compensation_sensor_uncompensated" - - assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - entity_id = config[DOMAIN]["test"]["source"] - hass.states.async_set(entity_id, 4, {}) + hass.states.async_set(TEST_SOURCE, 4, {}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 5.0 + assert round(float(state.state), config[CONF_PRECISION]) == 5.0 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "a" coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)] assert coefs == [1.0, 1.0] - hass.states.async_set(entity_id, "foo", {}) + hass.states.async_set(TEST_SOURCE, "foo", {}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN -async def test_linear_state_from_attribute(hass: HomeAssistant) -> None: - """Test compensation sensor state that pulls from attribute.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "attribute": "value", - "data_points": [ - [1.0, 2.0], - [2.0, 3.0], - ], - "precision": 2, - } - } - } - expected_entity_id = "sensor.compensation_sensor_uncompensated_value" - - assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, SENSOR_DOMAIN, config) +@pytest.mark.parametrize("config", [{"name": TEST_OBJECT_ID, **TEST_BASE_CONFIG}]) +@pytest.mark.usefixtures("setup_compensation") +async def test_attributes_come_from_source(hass: HomeAssistant) -> None: + """Test compensation sensor state.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.states.async_set( + TEST_SOURCE, + 4, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + ) await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == "5.0" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + +@pytest.mark.parametrize("config", [{"attribute": "value", **TEST_CONFIG}]) +@pytest.mark.usefixtures("setup_compensation") +async def test_linear_state_from_attribute( + hass: HomeAssistant, config: dict[str, Any] +) -> None: + """Test compensation sensor state that pulls from attribute.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - entity_id = config[DOMAIN]["test"]["source"] - hass.states.async_set(entity_id, 3, {"value": 4}) + hass.states.async_set(TEST_SOURCE, 3, {"value": 4}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 5.0 + assert round(float(state.state), config[CONF_PRECISION]) == 5.0 coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)] assert coefs == [1.0, 1.0] - hass.states.async_set(entity_id, 3, {"value": "bar"}) + hass.states.async_set(TEST_SOURCE, 3, {"value": "bar"}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN -async def test_quadratic_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "config", + [ + { + "name": TEST_OBJECT_ID, + "source": TEST_SOURCE, + "data_points": [ + [50, 3.3], + [50, 2.8], + [50, 2.9], + [70, 2.3], + [70, 2.6], + [70, 2.1], + [80, 2.5], + [80, 2.9], + [80, 2.4], + [90, 3.0], + [90, 3.1], + [90, 2.8], + [100, 3.3], + [100, 3.5], + [100, 3.0], + ], + "degree": 2, + "precision": 3, + }, + ], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_quadratic_state(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test 3 degree polynominial compensation sensor.""" - config = { - "compensation": { - "test": { - "source": "sensor.temperature", - "data_points": [ - [50, 3.3], - [50, 2.8], - [50, 2.9], - [70, 2.3], - [70, 2.6], - [70, 2.1], - [80, 2.5], - [80, 2.9], - [80, 2.4], - [90, 3.0], - [90, 3.1], - [90, 2.8], - [100, 3.3], - [100, 3.5], - [100, 3.0], - ], - "degree": 2, - "precision": 3, - } - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_SOURCE, 43.2, {}) await hass.async_block_till_done() - entity_id = config[DOMAIN]["test"]["source"] - hass.states.async_set(entity_id, 43.2, {}) - await hass.async_block_till_done() - - state = hass.states.get("sensor.compensation_sensor_temperature") + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 3.327 + assert round(float(state.state), config[CONF_PRECISION]) == 3.327 -async def test_numpy_errors( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +@pytest.mark.parametrize( + "config", + [ + { + "source": TEST_SOURCE, + "data_points": [ + [0.0, 1.0], + [0.0, 1.0], + ], + }, + ], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_numpy_errors(hass: HomeAssistant, caplog_setup_text) -> None: """Tests bad polyfits.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "data_points": [ - [0.0, 1.0], - [0.0, 1.0], - ], - }, - } - } - await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert "invalid value encountered in divide" in caplog.text + assert "invalid value encountered in divide" in caplog_setup_text async def test_datapoints_greater_than_degree( @@ -178,7 +236,7 @@ async def test_datapoints_greater_than_degree( config = { "compensation": { "test": { - "source": "sensor.uncompensated", + "source": TEST_SOURCE, "data_points": [ [1.0, 2.0], [2.0, 3.0], @@ -195,35 +253,13 @@ async def test_datapoints_greater_than_degree( assert "data_points must have at least 3 data_points" in caplog.text +@pytest.mark.parametrize("config", [TEST_CONFIG]) +@pytest.mark.usefixtures("setup_compensation") async def test_new_state_is_none(hass: HomeAssistant) -> None: """Tests catch for empty new states.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "data_points": [ - [1.0, 2.0], - [2.0, 3.0], - ], - "precision": 2, - "unit_of_measurement": "a", - } - } - } - expected_entity_id = "sensor.compensation_sensor_uncompensated" - - await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - last_changed = hass.states.get(expected_entity_id).last_changed - - hass.bus.async_fire( - EVENT_STATE_CHANGED, event_data={"entity_id": "sensor.uncompensated"} - ) - - assert last_changed == hass.states.get(expected_entity_id).last_changed + last_changed = hass.states.get(TEST_ENTITY_ID).last_changed + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data={"entity_id": TEST_SOURCE}) + assert last_changed == hass.states.get(TEST_ENTITY_ID).last_changed @pytest.mark.parametrize( @@ -234,40 +270,129 @@ async def test_new_state_is_none(hass: HomeAssistant) -> None: (True, True), ], ) +@pytest.mark.parametrize( + "config", + [ + { + "name": TEST_OBJECT_ID, + "source": TEST_SOURCE, + "data_points": [ + [1.0, 0.0], + [3.0, 2.0], + [2.0, 1.0], + ], + "precision": 2, + "unit_of_measurement": "a", + }, + ], +) +@pytest.mark.usefixtures("setup_compensation_with_limits") async def test_limits(hass: HomeAssistant, lower: bool, upper: bool) -> None: """Test compensation sensor state.""" - source = "sensor.test" - config = { - "compensation": { - "test": { - "source": source, - "data_points": [ - [1.0, 0.0], - [3.0, 2.0], - [2.0, 1.0], - ], - "precision": 2, - "lower_limit": lower, - "upper_limit": upper, - "unit_of_measurement": "a", - } - } - } - await async_setup_component(hass, DOMAIN, config) + hass.states.async_set(TEST_SOURCE, 0, {}) await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - entity_id = "sensor.compensation_sensor_test" - - hass.states.async_set(source, 0, {}) - await hass.async_block_till_done() - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) value = 0.0 if lower else -1.0 assert float(state.state) == value - hass.states.async_set(source, 5, {}) + hass.states.async_set(TEST_SOURCE, 5, {}) await hass.async_block_till_done() - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) value = 2.0 if upper else 4.0 assert float(state.state) == value + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + (TEST_BASE_CONFIG, "sensor.compensation_sensor_uncompensated"), + ( + {"attribute": "value", **TEST_BASE_CONFIG}, + "sensor.compensation_sensor_uncompensated_value", + ), + ], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_default_name(hass: HomeAssistant, expected: str) -> None: + """Test default configuration name.""" + assert hass.states.get(expected) is not None + + +@pytest.mark.parametrize("config", [TEST_CONFIG]) +@pytest.mark.parametrize( + ("source_state", "expected"), + [(STATE_UNKNOWN, STATE_UNKNOWN), (STATE_UNAVAILABLE, STATE_UNAVAILABLE)], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_non_numerical_states_from_source_entity( + hass: HomeAssistant, config: dict[str, Any], source_state: str, expected: str +) -> None: + """Test non-numerical states from source entity.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.states.async_set(TEST_SOURCE, source_state) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + + hass.states.async_set(TEST_SOURCE, 4) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert round(float(state.state), config[CONF_PRECISION]) == 5.0 + + hass.states.async_set(TEST_SOURCE, source_state) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + + +async def test_source_state_none(hass: HomeAssistant) -> None: + """Test is source sensor state is null and sets state to STATE_UNKNOWN.""" + config = { + "sensor": [ + { + "platform": "template", + "sensors": { + "uncompensated": { + "value_template": "{{ states.sensor.test_state.state }}" + } + }, + }, + ] + } + await async_setup_component(hass, "sensor", config) + await async_setup_compensation(hass, TEST_CONFIG) + + hass.states.async_set("sensor.test_state", 4) + + await hass.async_block_till_done() + state = hass.states.get(TEST_SOURCE) + assert state.state == "4" + + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == "5.0" + + # Force Template Reload + yaml_path = get_fixture_path("sensor_configuration.yaml", "template") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "template", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Template state gets to None + state = hass.states.get(TEST_SOURCE) + assert state is None + + # Filter sensor ignores None state setting state to STATE_UNKNOWN + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index c6e82976bf1..8f89549944c 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -625,7 +625,9 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "type": "form", "handler": "test", "step_id": "account", - "data_schema": [{"name": "user_title", "type": "string"}], + "data_schema": [ + {"name": "user_title", "required": False, "type": "string"} + ], "description_placeholders": None, "errors": None, "last_step": None, @@ -712,7 +714,9 @@ async def test_continue_flow_unauth( "type": "form", "handler": "test", "step_id": "account", - "data_schema": [{"name": "user_title", "type": "string"}], + "data_schema": [ + {"name": "user_title", "required": False, "type": "string"} + ], "description_placeholders": None, "errors": None, "last_step": None, @@ -1272,7 +1276,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> "type": "form", "handler": "test1", "step_id": "finish", - "data_schema": [{"name": "enabled", "type": "boolean"}], + "data_schema": [{"name": "enabled", "required": False, "type": "boolean"}], "description_placeholders": None, "errors": None, "last_step": None, @@ -1581,7 +1585,7 @@ async def test_subentry_flow_abort_duplicate(hass: HomeAssistant, client) -> Non "type": "form", "handler": ["test1", "test"], "step_id": "finish", - "data_schema": [{"name": "enabled", "type": "boolean"}], + "data_schema": [{"name": "enabled", "required": False, "type": "boolean"}], "description_placeholders": None, "errors": None, "last_step": None, @@ -1749,7 +1753,7 @@ async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: data = await resp.json() flow_id = data["flow_id"] expected_data = { - "data_schema": [{"name": "enabled", "type": "boolean"}], + "data_schema": [{"name": "enabled", "required": False, "type": "boolean"}], "description_placeholders": None, "errors": None, "flow_id": flow_id, diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 6575ab2ac98..19d8434fc5a 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -1,13 +1,14 @@ """Conversation test helpers.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch import pytest from homeassistant.components import conversation from homeassistant.components.shopping_list import intent as sl_intent from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from . import MockAgent @@ -15,6 +16,14 @@ from . import MockAgent from tests.common import MockConfigEntry +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + @pytest.fixture def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: """Mock agent that supports all languages.""" @@ -25,6 +34,19 @@ def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: return agent +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> conversation.ConversationInput: + """Return a conversation input instance.""" + return conversation.ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + @pytest.fixture(autouse=True) def mock_shopping_list_io(): """Stub out the persistence.""" @@ -51,4 +73,8 @@ async def sl_setup(hass: HomeAssistant): async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "conversation", {conversation.DOMAIN: {}}) + + # Disable fuzzy matching by default for tests + agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index ff8ebf724cd..787009ba614 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -3,12 +3,55 @@ list([ ]) # --- +# name: test_add_delta_content_stream[deltas10] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'native': object( + ), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas11] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Result', + }), + ]) +# --- # name: test_add_delta_content_stream[deltas1] list([ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -18,13 +61,17 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), dict({ 'agent_id': 'mock-agent-id', 'content': 'Test 2', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -34,9 +81,12 @@ dict({ 'agent_id': 'mock-agent-id', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -59,9 +109,12 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -84,9 +137,12 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -105,7 +161,9 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test 2', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -115,9 +173,12 @@ dict({ 'agent_id': 'mock-agent-id', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'mock-tool-call-id', 'tool_args': dict({ 'param1': 'Test Param 1', @@ -125,6 +186,7 @@ 'tool_name': 'test_tool', }), dict({ + 'external': False, 'id': 'mock-tool-call-id-2', 'tool_args': dict({ 'param1': 'Test Param 2', @@ -149,6 +211,45 @@ }), ]) # --- +# name: test_add_delta_content_stream[deltas7] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'native': None, + 'role': 'assistant', + 'thinking_content': 'Test Thinking', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas8] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'native': None, + 'role': 'assistant', + 'thinking_content': 'Test Thinking', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas9] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'native': dict({ + 'type': 'test', + 'value': 'Test Native', + }), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- # name: test_template_error dict({ 'continue_conversation': False, diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 5179409deb0..8b8ed6fa71c 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -45,7 +45,7 @@ 'nl', 'pl', 'pt', - 'pt-br', + 'pt-BR', 'ro', 'ru', 'sk', @@ -60,9 +60,9 @@ 'uk', 'ur', 'vi', - 'zh-cn', - 'zh-hk', - 'zh-tw', + 'zh-CN', + 'zh-HK', + 'zh-TW', ]), }), dict({ @@ -327,37 +327,6 @@ }), }) # --- -# name: test_http_processing_intent[homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_ws_api[payload0] dict({ 'continue_conversation': False, @@ -495,6 +464,7 @@ 'value': 'my cool light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOn', }), @@ -503,7 +473,6 @@ 'slots': dict({ 'name': 'my cool light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -520,6 +489,7 @@ 'value': 'my cool light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOff', }), @@ -528,7 +498,6 @@ 'slots': dict({ 'name': 'my cool light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -550,6 +519,7 @@ 'value': 'light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOn', }), @@ -559,7 +529,6 @@ 'area': 'kitchen', 'domain': 'light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -586,6 +555,7 @@ 'value': 'on', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassGetState', }), @@ -596,7 +566,6 @@ 'domain': 'lights', 'state': 'on', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': False, @@ -621,6 +590,7 @@ }), }), 'file': 'en/beer.yaml', + 'fuzzy_match': False, 'intent': dict({ 'name': 'OrderBeer', }), @@ -661,6 +631,7 @@ 'value': 'test light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassLightSet', }), @@ -670,7 +641,6 @@ 'brightness': '100', 'name': 'test light', }), - 'source': 'builtin', 'targets': dict({ 'light.demo_1234': dict({ 'matched': True, @@ -693,6 +663,7 @@ 'value': 'test light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassLightSet', }), @@ -701,7 +672,6 @@ 'slots': dict({ 'name': 'test light', }), - 'source': 'builtin', 'targets': dict({ }), 'unmatched_slots': dict({ diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index a853faa7a3d..779bb256180 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -108,37 +108,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -201,37 +170,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ 'continue_conversation': False, @@ -294,37 +232,6 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -387,34 +294,3 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 0e2a384f1da..e851512b36e 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -1,6 +1,5 @@ """Test the conversation session.""" -from collections.abc import Generator from dataclasses import asdict from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch @@ -26,27 +25,6 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -@pytest.fixture -def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: - """Return a conversation input instance.""" - return ConversationInput( - text="Hello", - context=Context(), - conversation_id=None, - agent_id="mock-agent-id", - device_id=None, - language="en", - ) - - -@pytest.fixture -def mock_ulid() -> Generator[Mock]: - """Mock the ulid library.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" - yield mock_ulid_now - - async def test_cleanup( hass: HomeAssistant, mock_conversation_input: ConversationInput, @@ -539,6 +517,48 @@ async def test_tool_call_exception( ] }, ], + # With thinking content + [ + {"role": "assistant"}, + {"thinking_content": "Test Thinking"}, + ], + # With content and thinking content + [ + {"role": "assistant"}, + {"content": "Test"}, + {"thinking_content": "Test Thinking"}, + ], + # With native content + [ + {"role": "assistant"}, + {"native": {"type": "test", "value": "Test Native"}}, + ], + # With native object content + [ + {"role": "assistant"}, + {"native": object()}, + ], + # With external tool calls + [ + {"role": "assistant"}, + {"content": "Test"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + external=True, + ) + ] + }, + { + "role": "tool_result", + "tool_call_id": "mock-tool-call-id", + "tool_name": "test_tool", + "tool_result": "Test Result", + }, + ], ], ) async def test_add_delta_content_stream( @@ -569,7 +589,9 @@ async def test_add_delta_content_stream( """Yield deltas.""" for d in deltas: yield d - expected_delta.append(d) + if filtered_delta := {k: v for k, v in d.items() if k != "native"}: + if filtered_delta.get("role") != "tool_result": + expected_delta.append(filtered_delta) captured_deltas = [] @@ -656,6 +678,20 @@ async def test_add_delta_content_stream_errors( ): pass + # Second native content + with pytest.raises(RuntimeError): + async for _tool_result_content in chat_log.async_add_delta_content_stream( + "mock-agent-id", + stream( + [ + {"role": "assistant"}, + {"native": "Test Native"}, + {"native": "Test Native 2"}, + ] + ), + ): + pass + async def test_chat_log_reuse( hass: HomeAssistant, diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index f075f267111..7c5e897d86c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -25,7 +25,12 @@ from homeassistant.components.intent import ( TimerInfo, async_register_timer_handler, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, + ColorMode, + intent as light_intent, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -81,6 +86,10 @@ async def init_components(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "conversation", {}) assert await async_setup_component(hass, "intent", {}) + # Disable fuzzy matching by default for tests + agent = hass.data[DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False + @pytest.mark.parametrize( "er_kwargs", @@ -3287,3 +3296,97 @@ async def test_language_with_alternative_code( assert call.domain == LIGHT_DOMAIN assert call.service == "turn_on" assert call.data == {"entity_id": [entity_id]} + + +@pytest.mark.parametrize("fuzzy_matching", [True, False]) +@pytest.mark.parametrize( + ("sentence", "intent_type", "slots"), + [ + ("time", "HassGetCurrentTime", {}), + ("how about my timers", "HassTimerStatus", {}), + ( + "the office needs more blue", + "HassLightSet", + {"area": "office", "color": "blue"}, + ), + ( + "50% office light", + "HassLightSet", + {"name": "office light", "brightness": "50%"}, + ), + ], +) +async def test_fuzzy_matching( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fuzzy_matching: bool, + sentence: str, + intent_type: str, + slots: dict[str, Any], +) -> None: + """Test fuzzy vs. non-fuzzy matching on some English sentences.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) + await light_intent.async_setup_intents(hass) + + agent = hass.data[DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = fuzzy_matching + + area_office = area_registry.async_get_or_create("office_id") + area_office = area_registry.async_update(area_office.id, name="office") + + entry = MockConfigEntry() + entry.add_to_hass(hass) + office_satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, + ) + device_registry.async_update_device(office_satellite.id, area_id=area_office.id) + + office_light = entity_registry.async_get_or_create("light", "demo", "1234") + office_light = entity_registry.async_update_entity( + office_light.entity_id, area_id=area_office.id + ) + hass.states.async_set( + office_light.entity_id, + "on", + attributes={ + ATTR_FRIENDLY_NAME: "office light", + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.BRIGHTNESS, ColorMode.RGB], + }, + ) + _on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + result = await conversation.async_converse( + hass, + sentence, + None, + Context(), + language="en", + device_id=office_satellite.id, + ) + response = result.response + + if not fuzzy_matching: + # Should not match + assert response.response_type == intent.IntentResponseType.ERROR + return + + assert response.response_type in ( + intent.IntentResponseType.ACTION_DONE, + intent.IntentResponseType.QUERY_ANSWER, + ) + assert response.intent is not None + assert response.intent.intent_type == intent_type + + # Verify slot texts match + actual_slots = { + slot_name: slot_value["text"] + for slot_name, slot_value in response.intent.slots.items() + if slot_name != "preferred_area_id" # context area + } + assert actual_slots == slots diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 77fa97ad845..29cd567e904 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -8,7 +8,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.conversation import default_agent -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -22,8 +25,6 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -187,7 +188,7 @@ async def test_http_api_wrong_data( }, { "text": "Test Text", - "agent_id": "homeassistant", + "agent_id": HOME_ASSISTANT_AGENT, }, ], ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index c3de5f1127c..e757c56042b 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -14,7 +14,10 @@ from homeassistant.components.conversation import ( async_handle_sentence_triggers, default_agent, ) -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -28,8 +31,6 @@ from tests.typing import ClientSessionGenerator AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -205,8 +206,8 @@ async def test_get_agent_info( """Test get agent info.""" agent_info = conversation.async_get_agent_info(hass) # Test it's the default - assert conversation.async_get_agent_info(hass, "homeassistant") == agent_info - assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == agent_info + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == snapshot assert ( conversation.async_get_agent_info(hass, mock_conversation_agent.agent_id) == snapshot @@ -223,7 +224,7 @@ async def test_get_agent_info( default_agent = conversation.async_get_agent(hass) default_agent._attr_supports_streaming = True assert ( - conversation.async_get_agent_info(hass, "homeassistant").supports_streaming + conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT).supports_streaming is True ) diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py new file mode 100644 index 00000000000..196de4ad2fb --- /dev/null +++ b/tests/components/conversation/test_util.py @@ -0,0 +1,39 @@ +"""Tests for conversation utility functions.""" + +from homeassistant.components import conversation +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, intent, llm + + +async def test_async_get_result_from_chat_log( + hass: HomeAssistant, + mock_conversation_input: conversation.ConversationInput, +) -> None: + """Test getting result from chat log.""" + intent_response = intent.IntentResponse(language="en") + with ( + chat_session.async_get_chat_session(hass) as session, + conversation.async_get_chat_log( + hass, session, mock_conversation_input + ) as chat_log, + ): + chat_log.content.extend( + [ + conversation.ToolResultContent( + agent_id="mock-agent-id", + tool_call_id="mock-tool-call-id", + tool_name="mock-tool-name", + tool_result=llm.IntentResponseDict(intent_response), + ), + conversation.AssistantContent( + agent_id="mock-agent-id", + content="This is a response.", + ), + ] + ) + result = conversation.async_get_result_from_chat_log( + mock_conversation_input, chat_log + ) + # Original intent response is returned with speech set + assert result.response is intent_response + assert result.response.speech["plain"]["speech"] == "This is a response." diff --git a/tests/components/coolmaster/test_init.py b/tests/components/coolmaster/test_init.py index f8ff761517f..cd3693c513c 100644 --- a/tests/components/coolmaster/test_init.py +++ b/tests/components/coolmaster/test_init.py @@ -1,7 +1,12 @@ """The test for the Coolmaster integration.""" +from homeassistant.components.coolmaster.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator async def test_load_entry( @@ -22,3 +27,45 @@ async def test_unload_entry( await hass.config_entries.async_unload(load_int.entry_id) await hass.async_block_till_done() assert load_int.state is ConfigEntryState.NOT_LOADED + + +async def test_registry_cleanup( + hass: HomeAssistant, + load_int: ConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test being able to remove a disconnected device.""" + entry_id = load_int.entry_id + device_registry = dr.async_get(hass) + live_id = "L1.100" + dead_id = "L2.200" + + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + device_registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, dead_id)}, + manufacturer="CoolAutomation", + model="CoolMasterNet", + name=dead_id, + sw_version="1.0", + ) + + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3 + + assert await async_setup_component(hass, "config", {}) + client = await hass_ws_client(hass) + # Try to remove "L1.100" - fails since it is live + device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) + assert device is not None + response = await client.remove_device(device.id, entry_id) + assert not response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3 + assert device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) is not None + + # Try to remove "L2.200" - succeeds since it is dead + device = device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) + assert device is not None + response = await client.remove_device(device.id, entry_id) + assert response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + assert device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) is None diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index ef2caf2eab1..c5595d7fcbe 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -73,12 +73,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index db9e75bcaef..438e5de751d 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -260,6 +260,7 @@ async def test_get_action_capabilities_set_pos( { "name": "position", "optional": True, + "required": False, "type": "integer", "default": 0, "valueMax": 100, @@ -310,6 +311,7 @@ async def test_get_action_capabilities_set_tilt_pos( { "name": "position", "optional": True, + "required": False, "type": "integer", "default": 0, "valueMax": 100, diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index aa5f150172c..5bd02120585 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -254,6 +254,7 @@ async def test_get_condition_capabilities_set_pos( { "name": "above", "optional": True, + "required": False, "type": "integer", "default": 0, "valueMax": 100, @@ -262,6 +263,7 @@ async def test_get_condition_capabilities_set_pos( { "name": "below", "optional": True, + "required": False, "type": "integer", "default": 100, "valueMax": 100, @@ -311,6 +313,7 @@ async def test_get_condition_capabilities_set_tilt_pos( { "name": "above", "optional": True, + "required": False, "type": "integer", "default": 0, "valueMax": 100, @@ -319,6 +322,7 @@ async def test_get_condition_capabilities_set_tilt_pos( { "name": "below", "optional": True, + "required": False, "type": "integer", "default": 100, "valueMax": 100, diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index 7901baaa3b8..1a6b50b2935 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -192,7 +192,12 @@ async def test_get_trigger_capabilities( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } @@ -230,7 +235,12 @@ async def test_get_trigger_capabilities_legacy( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } @@ -262,6 +272,7 @@ async def test_get_trigger_capabilities_set_pos( { "name": "above", "optional": True, + "required": False, "type": "integer", "default": 0, "valueMax": 100, @@ -270,6 +281,7 @@ async def test_get_trigger_capabilities_set_pos( { "name": "below", "optional": True, + "required": False, "type": "integer", "default": 100, "valueMax": 100, @@ -293,6 +305,7 @@ async def test_get_trigger_capabilities_set_pos( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ] @@ -326,6 +339,7 @@ async def test_get_trigger_capabilities_set_tilt_pos( { "name": "above", "optional": True, + "required": False, "type": "integer", "default": 0, "valueMax": 100, @@ -334,6 +348,7 @@ async def test_get_trigger_capabilities_set_tilt_pos( { "name": "below", "optional": True, + "required": False, "type": "integer", "default": 100, "valueMax": 100, @@ -357,6 +372,7 @@ async def test_get_trigger_capabilities_set_tilt_pos( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ] diff --git a/tests/components/datadog/common.py b/tests/components/datadog/common.py new file mode 100644 index 00000000000..07539dc0e07 --- /dev/null +++ b/tests/components/datadog/common.py @@ -0,0 +1,35 @@ +"""Common helpers for the datetime entity component tests.""" + +from unittest import mock + +MOCK_DATA = { + "host": "localhost", + "port": 8125, +} + +MOCK_OPTIONS = { + "prefix": "hass", + "rate": 1, +} + +MOCK_CONFIG = {**MOCK_DATA, **MOCK_OPTIONS} + +MOCK_YAML_INVALID = { + "host": "127.0.0.1", + "port": 65535, + "prefix": "failtest", + "rate": 1, +} + + +CONNECTION_TEST_METRIC = "connection_test" + + +def create_mock_state(entity_id, state, attributes=None): + """Helper to create a mock state object.""" + mock_state = mock.MagicMock() + mock_state.entity_id = entity_id + mock_state.state = state + mock_state.domain = entity_id.split(".")[0] + mock_state.attributes = attributes or {} + return mock_state diff --git a/tests/components/datadog/test_config_flow.py b/tests/components/datadog/test_config_flow.py new file mode 100644 index 00000000000..1d181774fbe --- /dev/null +++ b/tests/components/datadog/test_config_flow.py @@ -0,0 +1,257 @@ +"""Tests for the Datadog config flow.""" + +from unittest.mock import MagicMock, patch + +from homeassistant.components import datadog +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import homeassistant.helpers.issue_registry as ir + +from .common import MOCK_CONFIG, MOCK_DATA, MOCK_OPTIONS, MOCK_YAML_INVALID + +from tests.common import MockConfigEntry + + +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test user-initiated config flow.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["title"] == f"Datadog {MOCK_CONFIG['host']}" + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == MOCK_DATA + assert result2["options"] == MOCK_OPTIONS + + +async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> None: + """Test connection failure.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("Connection failed"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_DATA + assert result3["options"] == MOCK_OPTIONS + + +async def test_user_flow_abort_already_configured_service( + hass: HomeAssistant, +) -> None: + """Abort user-initiated config flow if the same host/port is already configured.""" + existing_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test that the options flow shows an error when connection fails.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection failed"), + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_OPTIONS + + +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers config flow and is accepted.""" + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA + assert result["options"] == MOCK_OPTIONS + + await hass.async_block_till_done() + + # Deprecation issue should be created + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_datadog" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_import_connection_error( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers connection error issue.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection refused"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_YAML_INVALID, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + issue = issue_registry.async_get_issue( + datadog.DOMAIN, "deprecated_yaml_import_connection_error" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml_import_connection_error" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test updating options after setup.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + new_options = { + "prefix": "updated", + "rate": 5, + } + + # OSError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # ValueError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=ValueError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Success Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == new_options + mock_instance.increment.assert_called_once_with("connection_test") + + +async def test_import_flow_abort_already_configured_service( + hass: HomeAssistant, +) -> None: + """Abort import if the same host/port is already configured.""" + existing_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 3b7bea3c926..7ab9e0cb97a 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -4,73 +4,98 @@ from unittest import mock from unittest.mock import patch from homeassistant.components import datadog -from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON +from homeassistant.components.datadog import async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component +from .common import MOCK_DATA, MOCK_OPTIONS, create_mock_state + +from tests.common import EVENT_STATE_CHANGED, MockConfigEntry async def test_invalid_config(hass: HomeAssistant) -> None: """Test invalid configuration.""" - with assert_setup_component(0): - assert not await async_setup_component( - hass, datadog.DOMAIN, {datadog.DOMAIN: {"host1": "host1"}} - ) + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={"host1": "host1"}, + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) async def test_datadog_setup_full(hass: HomeAssistant) -> None: """Test setup with all data.""" - config = {datadog.DOMAIN: {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} - with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch("homeassistant.components.datadog.DogStatsd") as mock_dogstatsd, ): - assert await async_setup_component(hass, datadog.DOMAIN, config) + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": "host", + "port": 123, + }, + options={ + "rate": 1, + "prefix": "foo", + }, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=123) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call( + host="host", port=123, namespace="foo", disable_telemetry=True + ) async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch("homeassistant.components.datadog.DogStatsd") as mock_dogstatsd, ): - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "port": datadog.DEFAULT_PORT, - "prefix": datadog.DEFAULT_PREFIX, - } - }, + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=8125) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call( + host="localhost", port=8125, namespace="hass", disable_telemetry=True + ) async def test_logbook_entry(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): - assert await async_setup_component( - hass, - datadog.DOMAIN, - {datadog.DOMAIN: {"host": "host", "rate": datadog.DEFAULT_RATE}}, + mock_statsd = mock_statsd_class.return_value + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": datadog.DEFAULT_HOST, + "port": datadog.DEFAULT_PORT, + }, + options={ + "rate": datadog.DEFAULT_RATE, + "prefix": datadog.DEFAULT_PREFIX, + }, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) event = { "domain": "automation", "entity_id": "sensor.foo.bar", - "message": "foo bar biz", + "message": "foo bar baz", "name": "triggered something", } hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, event) @@ -79,42 +104,37 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: assert mock_statsd.event.call_count == 1 assert mock_statsd.event.call_args == mock.call( title="Home Assistant", - text=f"%%% \n **{event['name']}** {event['message']} \n %%%", + message=f"%%% \n **{event['name']}** {event['message']} \n %%%", tags=["entity:sensor.foo.bar", "domain:automation"], ) - mock_statsd.event.reset_mock() - async def test_state_changed(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "prefix": "ha", - "rate": datadog.DEFAULT_RATE, - } + mock_statsd = mock_statsd_class.return_value + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": "host", + "port": datadog.DEFAULT_PORT, }, + options={"prefix": "ha", "rate": datadog.DEFAULT_RATE}, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) valid = {"1": 1, "1.0": 1.0, STATE_ON: 1, STATE_OFF: 0} attributes = {"elevation": 3.2, "temperature": 5.0, "up": True, "down": False} for in_, out in valid.items(): - state = mock.MagicMock( - domain="sensor", - entity_id="sensor.foobar", - state=in_, - attributes=attributes, - ) + state = create_mock_state("sensor.foobar", in_, attributes) hass.states.async_set(state.entity_id, state.state, state.attributes) await hass.async_block_till_done() assert mock_statsd.gauge.call_count == 5 @@ -145,3 +165,60 @@ async def test_state_changed(hass: HomeAssistant) -> None: hass.states.async_set("domain.test", invalid, {}) await hass.async_block_till_done() assert not mock_statsd.gauge.called + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unloading the config entry cleans up properly.""" + client = mock.MagicMock() + + with ( + patch("homeassistant.components.datadog.DogStatsd", return_value=client), + patch("homeassistant.components.datadog.initialize"), + ): + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + client.flush.assert_called_once() + client.close_socket.assert_called_once() + + +async def test_state_changed_skips_unknown(hass: HomeAssistant) -> None: + """Test state_changed_listener skips None and unknown states.""" + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + entry.add_to_hass(hass) + + await async_setup_entry(hass, entry) + + # Test None state + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": None}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called + + # Test STATE_UNKNOWN + unknown_state = mock.MagicMock() + unknown_state.state = STATE_UNKNOWN + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": unknown_state}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index 06067b69c17..59e77c4fb12 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -17,7 +17,6 @@ '01234E56789A', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Dresden Elektronik', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/deconz/snapshots/test_sensor.ambr b/tests/components/deconz/snapshots/test_sensor.ambr index 04f93738b18..4a6bc43043b 100644 --- a/tests/components/deconz/snapshots/test_sensor.ambr +++ b/tests/components/deconz/snapshots/test_sensor.ambr @@ -829,7 +829,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'xx:xx:xx:xx:xx:xx:xx:xx-01-042a-particulate_matter_pm2_5', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[config_entry_options0-sensor_payload14-expected14][sensor.starkvind_airpurifier_pm25-state] @@ -838,7 +838,7 @@ 'device_class': 'pm25', 'friendly_name': 'STARKVIND AirPurifier PM25', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.starkvind_airpurifier_pm25', @@ -1377,7 +1377,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_formaldehyde', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[config_entry_options0-sensor_payload2-expected2][sensor.airquality_1_ch2o-state] @@ -1386,7 +1386,7 @@ 'device_class': 'volatile_organic_compounds', 'friendly_name': 'AirQuality 1 CH2O', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airquality_1_ch2o', @@ -1483,7 +1483,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_pm2_5', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[config_entry_options0-sensor_payload2-expected2][sensor.airquality_1_pm25-state] @@ -1492,7 +1492,7 @@ 'device_class': 'pm25', 'friendly_name': 'AirQuality 1 PM25', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airquality_1_pm25', @@ -1699,7 +1699,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_formaldehyde', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[config_entry_options0-sensor_payload3-expected3][sensor.airquality_1_ch2o-state] @@ -1708,7 +1708,7 @@ 'device_class': 'volatile_organic_compounds', 'friendly_name': 'AirQuality 1 CH2O', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airquality_1_ch2o', @@ -1805,7 +1805,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_pm2_5', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[config_entry_options0-sensor_payload3-expected3][sensor.airquality_1_pm25-state] @@ -1814,7 +1814,7 @@ 'device_class': 'pm25', 'friendly_name': 'AirQuality 1 PM25', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airquality_1_pm25', @@ -1910,7 +1910,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_formaldehyde', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[config_entry_options0-sensor_payload4-expected4][sensor.airquality_1_ch2o-state] @@ -1919,7 +1919,7 @@ 'device_class': 'volatile_organic_compounds', 'friendly_name': 'AirQuality 1 CH2O', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airquality_1_ch2o', @@ -2016,7 +2016,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00:00:01-02-0113-air_quality_pm2_5', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[config_entry_options0-sensor_payload4-expected4][sensor.airquality_1_pm25-state] @@ -2025,7 +2025,7 @@ 'device_class': 'pm25', 'friendly_name': 'AirQuality 1 PM25', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.airquality_1_pm25', diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 7487a4c13e3..c22b28ae799 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -55,7 +55,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION, _make_key from homeassistant.setup import async_setup_component -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_ENTITY_ID = "media_player.walkman" @@ -563,3 +563,32 @@ async def test_grouping(hass: HomeAssistant) -> None: ) state = hass.states.get(walkman) assert state.attributes.get(ATTR_GROUP_MEMBERS) == [] + + +async def test_browse( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the media player browse.""" + entity = "media_player.browse" + + await async_setup_component(hass, "media_source", {"media_source": {}}) + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": entity, + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert msg["result"]["title"] == "media" + assert msg["result"]["media_class"] == "directory" + assert len(msg["result"]["children"]) diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index f910e6e53ac..a497bd964ec 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -14,7 +14,6 @@ from homeassistant.components.demo.vacuum import ( FAN_SPEEDS, ) from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, @@ -38,11 +37,15 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common -ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".lower() -ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() -ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() -ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".lower() -ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".lower() +ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".replace(" ", "_").lower() +ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".replace(" ", "_").lower() +ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".replace(" ", "_").lower() @pytest.fixture @@ -67,36 +70,31 @@ async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): async def test_supported_features(hass: HomeAssistant) -> None: """Test vacuum supported features.""" state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 16380 - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 16316 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MOST) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12412 - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12348 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12360 - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12296 assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MINIMAL) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 - assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_NONE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.state == VacuumActivity.DOCKED @@ -116,7 +114,6 @@ async def test_methods(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.state == VacuumActivity.DOCKED await async_setup_component(hass, "notify", {}) diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 440df495995..5e2d9446cdc 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -68,8 +68,14 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: @pytest.mark.parametrize("platform", ["sensor"]) -async def test_options(hass: HomeAssistant, platform) -> None: - """Test reconfiguring.""" +@pytest.mark.parametrize( + ("unit_prefix_entry", "unit_prefix_used"), + [("k", "k"), ("\u00b5", "\u03bc"), ("\u03bc", "\u03bc")], +) +async def test_options( + hass: HomeAssistant, platform, unit_prefix_entry: str, unit_prefix_used: str +) -> None: + """Test reconfiguring and migrated unit prefix.""" # Setup the config entry config_entry = MockConfigEntry( data={}, @@ -79,7 +85,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "round": 1.0, "source": "sensor.input", "time_window": {"seconds": 0.0}, - "unit_prefix": "k", + "unit_prefix": unit_prefix_entry, "unit_time": "min", "max_sub_interval": {"seconds": 30}, }, @@ -99,7 +105,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: schema = result["data_schema"].schema assert get_schema_suggested_value(schema, "round") == 1.0 assert get_schema_suggested_value(schema, "time_window") == {"seconds": 0.0} - assert get_schema_suggested_value(schema, "unit_prefix") == "k" + assert get_schema_suggested_value(schema, "unit_prefix") == unit_prefix_used assert get_schema_suggested_value(schema, "unit_time") == "min" source = schema["source"] diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index d237703eb2e..005e6ec91d9 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components import derivative from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -82,6 +82,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -99,6 +100,9 @@ async def test_setup_and_remove_config_entry( input_sensor_entity_id = "sensor.input" derivative_entity_id = "sensor.my_derivative" + hass.states.async_set(input_sensor_entity_id, "10.0", {}) + await hass.async_block_till_done() + # Setup the config entry config_entry = MockConfigEntry( data={}, @@ -211,7 +215,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(derivative_config_entry.entry_id) @@ -226,7 +230,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -237,6 +241,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the derivative config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the derivative config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -253,7 +305,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -270,7 +322,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -297,7 +353,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -312,7 +368,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -345,7 +405,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert derivative_config_entry.entry_id not in sensor_device_2.config_entries @@ -362,11 +422,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is moved to the other device + # Check that the entity is linked to the other device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert derivative_config_entry.entry_id in sensor_device_2.config_entries + assert derivative_config_entry.entry_id not in sensor_device_2.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -391,7 +455,7 @@ async def test_async_handle_source_entity_new_entity_id( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -409,12 +473,165 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the derivative config entry is updated with the new entity ID assert derivative_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +@pytest.mark.parametrize( + ("unit_prefix", "expect_prefix"), + [ + ({}, None), + ({"unit_prefix": "k"}, "k"), + ({"unit_prefix": "none"}, None), + ], +) +async def test_migration_1_1(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: + """Test migration from v1.1 deletes "none" unit_prefix.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.power", + "time_window": {"seconds": 0.0}, + **unit_prefix, + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options["unit_time"] == "min" + assert config_entry.options.get("unit_prefix") == expect_prefix + + assert config_entry.version == 1 + assert config_entry.minor_version == 4 + + +async def test_migration_1_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.2 removes derivative config entry from device.""" + + derivative_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.test_unique", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=2, + ) + derivative_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + assert derivative_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + assert derivative_config_entry.version == 1 + assert derivative_config_entry.minor_version == 4 + + +@pytest.mark.parametrize( + ("unit_prefix", "expect_prefix"), + [ + ({"unit_prefix": "\u00b5"}, "\u03bc"), + ({"unit_prefix": "\u03bc"}, "\u03bc"), + ], +) +async def test_migration_1_4(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: + """Test migration from v1.4 migrates to Greek Mu char" unit_prefix.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.power", + "time_window": {"seconds": 0.0}, + **unit_prefix, + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options["unit_time"] == "min" + assert config_entry.options.get("unit_prefix") == expect_prefix + + assert config_entry.version == 1 + assert config_entry.minor_version == 4 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.power", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index e4e7097341c..211e6f673ca 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -6,19 +6,53 @@ import random from typing import Any from freezegun import freeze_time +import pytest from homeassistant.components.derivative.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import STATE_UNAVAILABLE, UnitOfPower, UnitOfTime -from homeassistant.core import HomeAssistant, State +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfPower, + UnitOfTime, +) +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache_with_extra_data, +) + +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} -async def test_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2], + ], +) +async def test_state( + hass: HomeAssistant, + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state.""" config = { "sensor": { @@ -35,12 +69,13 @@ async def test_state(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + for extra_attributes in attributes: + hass.states.async_set( + entity_id, 1, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None @@ -51,8 +86,33 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" -async def test_no_change(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1, A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2, A1, A2], + ], +) +async def test_no_change( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state updated when source sensor doesn't change.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.derivative", _capture_event) + config = { "sensor": { "platform": "derivative", @@ -61,33 +121,36 @@ async def test_no_change(hass: HomeAssistant) -> None: "unit": "kW", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() + for value, extra_attributes in zip([0, 1, 1, 1], attributes, strict=True): + hass.states.async_set( + entity_id, value, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None + await hass.async_block_till_done() + await hass.async_block_till_done() + states = [events[0].data["new_state"].state] + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[1:] + ] # Testing a energy sensor at 1 kWh for 1hour = 0kW - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + assert states == ["unavailable", 0.0, 1.0, 0.0] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == "kW" @@ -106,6 +169,7 @@ async def _setup_sensor( config = {"sensor": dict(default_config, **config)} assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() entity_id = config["sensor"]["source"] hass.states.async_set(entity_id, 0, {}) @@ -127,7 +191,7 @@ async def setup_tests( # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() @@ -202,7 +266,24 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) -async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1] * 10 + [A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2] * 10 + [A1], + ], +) +async def test_data_moving_average_with_zeroes( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test that zeroes are properly handled within the time window.""" # We simulate the following situation: # The temperature rises 1 °C per minute for 10 minutes long. Then, it @@ -211,6 +292,14 @@ async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: # Therefore, we can expect the derivative to peak at 1 after 10 minutes # and then fall down to 0 in steps of 10%. + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.power", _capture_event) + temperature_values = [] for temperature in range(10): temperature_values += [temperature] @@ -224,29 +313,38 @@ async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: "time_window": {"seconds": time_window}, "unit_time": UnitOfTime.MINUTES, "round": 1, - }, + } + | extra_config, ) base = dt_util.utcnow() with freeze_time(base) as freezer: last_derivative = 0 - for time, value in zip(times, temperature_values, strict=True): + for time, value, extra_attributes in zip( + times, temperature_values, attributes, strict=True + ): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}) - await hass.async_block_till_done() + hass.states.async_set( + entity_id, value, extra_attributes, force_update=force_update + ) - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) + await hass.async_block_till_done() + await hass.async_block_till_done() - if time_window == time: - assert derivative == 1.0 - elif time_window < time < time_window * 2: - assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) - elif time == time_window * 2: - assert derivative == 0 + assert len(events[2:]) == len(times) + for time, event in zip(times, events[2:], strict=True): + state = event.data["new_state"] + derivative = round(float(state.state), config["sensor"]["round"]) - last_derivative = derivative + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: @@ -262,7 +360,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for temperature in range(30): temperature_values += [temperature] * 2 # two values per minute time_window = 600 - times = list(range(0, 1800 + 30, 30)) + times = list(range(0, 1800, 30)) config, entity_id = await _setup_sensor( hass, @@ -275,7 +373,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -319,7 +417,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -357,7 +455,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: base = dt_util.utcnow() previous = 0 with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -440,16 +538,14 @@ async def test_sub_intervals_instantaneous(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - assert derivative == -0.29 + assert state.state == STATE_UNAVAILABLE now += timedelta(seconds=60) async_fire_time_changed(hass, now) await hass.async_block_till_done() state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - assert derivative == -0.29 + assert state.state == STATE_UNAVAILABLE now += timedelta(seconds=10) freezer.move_to(now) @@ -458,7 +554,7 @@ async def test_sub_intervals_instantaneous(hass: HomeAssistant) -> None: state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) - assert derivative == -0.29 + assert derivative == 0 now += timedelta(seconds=max_sub_interval + 1) async_fire_time_changed(hass, now) @@ -497,7 +593,7 @@ async def test_sub_intervals_with_time_window(hass: HomeAssistant) -> None: base = dt_util.utcnow() with freeze_time(base) as freezer: last_state_change = None - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) @@ -627,7 +723,7 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: actual_times = [] actual_values = [] with freeze_time(base_time) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): current_time = base_time + timedelta(seconds=time) freezer.move_to(current_time) hass.states.async_set( @@ -693,3 +789,148 @@ async def test_device_id( derivative_entity = entity_registry.async_get("sensor.derivative") assert derivative_entity is not None assert derivative_entity.device_id == source_entity.device_id + + +@pytest.mark.parametrize("bad_state", [STATE_UNAVAILABLE, STATE_UNKNOWN, "foo"]) +async def test_unavailable( + bad_state: str, + hass: HomeAssistant, +) -> None: + """Test derivative sensor state when unavailable.""" + config, entity_id = await _setup_sensor(hass, {"unit_time": "s"}) + + times = [0, 1, 2, 3] + values = [0, 1, bad_state, 2] + expected_state = [ + 0, + 1, + STATE_UNAVAILABLE if bad_state == STATE_UNAVAILABLE else STATE_UNKNOWN, + 0.5, + ] + + # Testing a energy sensor with non-monotonic intervals and values + base = dt_util.utcnow() + with freeze_time(base) as freezer: + for time, value, expect in zip(times, values, expected_state, strict=True): + freezer.move_to(base + timedelta(seconds=time)) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + rounded_state = ( + state.state + if expect in [STATE_UNKNOWN, STATE_UNAVAILABLE] + else round(float(state.state), config["sensor"]["round"]) + ) + assert rounded_state == expect + + +@pytest.mark.parametrize("bad_state", [STATE_UNAVAILABLE, STATE_UNKNOWN, "foo"]) +async def test_unavailable_2( + bad_state: str, + hass: HomeAssistant, +) -> None: + """Test derivative sensor state when unavailable with a time window.""" + config, entity_id = await _setup_sensor( + hass, {"unit_time": "s", "time_window": {"seconds": 10}} + ) + + # Monotonically increasing by 1, with some unavailable holes + times = list(range(21)) + values = list(range(21)) + values[3] = bad_state + values[6] = bad_state + values[7] = bad_state + values[8] = bad_state + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + for time, value in zip(times, values, strict=True): + freezer.move_to(base + timedelta(seconds=time)) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + + if value == bad_state: + assert ( + state.state == STATE_UNAVAILABLE + if bad_state is STATE_UNAVAILABLE + else STATE_UNKNOWN + ) + else: + expect = (time / 10) if time < 10 else 1 + assert round(float(state.state), config["sensor"]["round"]) == round( + expect, config["sensor"]["round"] + ) + + +@pytest.mark.parametrize("restore_state", ["3.00", STATE_UNKNOWN]) +async def test_unavailable_boot( + restore_state, + hass: HomeAssistant, +) -> None: + """Test that the booting sequence does not leave derivative in a bad state.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.power", + restore_state, + { + "unit_of_measurement": "W", + }, + ), + { + "native_value": restore_state, + "native_unit_of_measurement": "W", + }, + ), + ], + ) + + config = { + "platform": "derivative", + "name": "power", + "source": "sensor.energy", + "round": 2, + "unit_time": "s", + } + + config = {"sensor": config} + entity_id = config["sensor"]["source"] + hass.states.async_set(entity_id, STATE_UNAVAILABLE, {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + # Sensor is unavailable as source is unavailable + assert state.state == STATE_UNAVAILABLE + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + freezer.move_to(base + timedelta(seconds=1)) + hass.states.async_set(entity_id, 10, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + # The source sensor has moved to a valid value, but we need 2 points to derive, + # so just hold until the next tick + assert state.state == restore_state + + freezer.move_to(base + timedelta(seconds=2)) + hass.states.async_set(entity_id, 15, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + # Now that the source sensor has two valid datapoints, we can calculate derivative + assert state.state == "5.00" diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 94625746b05..456202a63a4 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -293,7 +293,9 @@ async def test_websocket_get_action_capabilities( ) expected_capabilities = { "turn_on": { - "extra_fields": [{"type": "string", "name": "code", "optional": True}] + "extra_fields": [ + {"type": "string", "name": "code", "optional": True, "required": False} + ] }, "turn_off": {"extra_fields": []}, "toggle": {"extra_fields": []}, @@ -452,7 +454,12 @@ async def test_websocket_get_condition_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } @@ -745,7 +752,12 @@ async def test_websocket_get_trigger_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index b2a58ef5038..657e93a5b90 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -5,9 +5,10 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import configure_integration from .mocks import ( @@ -19,7 +20,10 @@ from .mocks import ( async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) @@ -55,6 +59,12 @@ async def test_binary_sensor( hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door").state == STATE_UNAVAILABLE ) + # Emulate websocket message: device was deleted + test_gateway.publisher.dispatch("Test", ("Test", "del")) + await hass.async_block_till_done() + device = device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) + assert not device + async def test_remote_control( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py index 46adaf8c8b0..0a66760bc81 100644 --- a/tests/components/devolo_home_control/test_switch.py +++ b/tests/components/devolo_home_control/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -20,7 +21,10 @@ from .mocks import HomeControlMock, HomeControlMockSwitch async def test_switch( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + caplog: pytest.LogCaptureFixture, ) -> None: """Test setup and state change of a switch device.""" entry = configure_integration(hass) @@ -69,6 +73,14 @@ async def test_switch( test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() assert hass.states.get(f"{SWITCH_DOMAIN}.test").state == STATE_UNAVAILABLE + assert "Device Test is unavailable" in caplog.text + + # Emulate websocket message: device went back online + test_gateway.devices["Test"].status = 0 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{SWITCH_DOMAIN}.test").state == STATE_ON + assert "Device Test is back online" in caplog.text async def test_remove_from_hass(hass: HomeAssistant) -> None: diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 5753fd82817..13603beb8b4 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_entry[mock_device] +# name: test_device[mock_device] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -21,7 +21,6 @@ '1234567890', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'devolo', @@ -31,12 +30,11 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': '5.6.1', 'via_device_id': None, }) # --- -# name: test_setup_entry[mock_ipv6_device] +# name: test_device[mock_ipv6_device] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -58,7 +56,6 @@ '1234567890', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'devolo', @@ -68,12 +65,11 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': '5.6.1', 'via_device_id': None, }) # --- -# name: test_setup_entry[mock_repeater_device] +# name: test_device[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -91,7 +87,6 @@ '1234567890', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'devolo', @@ -101,7 +96,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': '5.6.1', 'via_device_id': None, }) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index c25aff7e9ad..9c609334718 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -25,28 +25,14 @@ from .const import IP from .mock import MockDevice -@pytest.mark.parametrize( - "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] -) -async def test_setup_entry( - hass: HomeAssistant, - device: str, - device_registry: dr.DeviceRegistry, - snapshot: SnapshotAssertion, - request: pytest.FixtureRequest, -) -> None: +@pytest.mark.usefixtures("mock_device") +async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry.""" - mock_device: MockDevice = request.getfixturevalue(device) entry = configure_integration(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - device_info = device_registry.async_get_device( - {(DOMAIN, mock_device.serial_number)} - ) - assert device_info == snapshot - async def test_setup_device_not_found(hass: HomeAssistant) -> None: """Test setup entry.""" @@ -79,6 +65,26 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: mock_device.async_disconnect.assert_called_once() +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) +async def test_device( + hass: HomeAssistant, + device: str, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, +) -> None: + """Test device setup.""" + mock_device: MockDevice = request.getfixturevalue(device) + entry = configure_integration(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + device_info = device_registry.async_get_device( + {(DOMAIN, mock_device.serial_number)} + ) + assert device_info == snapshot + + @pytest.mark.parametrize( ("device", "expected_platforms"), [ diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py index ffed7e21f60..fe62efeebac 100644 --- a/tests/components/diagnostics/test_init.py +++ b/tests/components/diagnostics/test_init.py @@ -1,13 +1,15 @@ """Test the Diagnostics integration.""" +from datetime import datetime from http import HTTPStatus from unittest.mock import AsyncMock, Mock, patch +from freezegun import freeze_time import pytest from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.system_info import async_get_system_info from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -81,10 +83,20 @@ async def test_websocket( @pytest.mark.usefixtures("enable_custom_integrations") +@pytest.mark.parametrize( + "ignore_missing_translations", + [ + [ + "component.fake_integration.issues.test_issue.title", + "component.fake_integration.issues.test_issue.description", + ] + ], +) async def test_download_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test download diagnostics.""" config_entry = MockConfigEntry(domain="fake_integration") @@ -95,6 +107,18 @@ async def test_download_diagnostics( integration = await async_get_integration(hass, "fake_integration") original_manifest = integration.manifest.copy() original_manifest["codeowners"] = ["@test"] + + with freeze_time(datetime(2025, 7, 9, 14, 00, 00)): + issue_registry.async_get_or_create( + domain="fake_integration", + issue_id="test_issue", + breaks_in_ha_version="2023.10.0", + severity=ir.IssueSeverity.WARNING, + is_fixable=False, + is_persistent=True, + translation_key="test_issue", + ) + with patch.object(integration, "manifest", original_manifest): response = await _get_diagnostics_for_config_entry( hass, hass_client, config_entry @@ -179,6 +203,23 @@ async def test_download_diagnostics( "requirements": [], }, "data": {"config_entry": "info"}, + "issues": [ + { + "breaks_in_ha_version": "2023.10.0", + "created": "2025-07-09T14:00:00+00:00", + "data": None, + "dismissed_version": None, + "domain": "fake_integration", + "is_fixable": False, + "is_persistent": True, + "issue_domain": None, + "issue_id": "test_issue", + "learn_more_url": None, + "severity": "warning", + "translation_key": "test_issue", + "translation_placeholders": None, + }, + ], } device = device_registry.async_get_or_create( @@ -266,6 +307,23 @@ async def test_download_diagnostics( "requirements": [], }, "data": {"device": "info"}, + "issues": [ + { + "breaks_in_ha_version": "2023.10.0", + "created": "2025-07-09T14:00:00+00:00", + "data": None, + "dismissed_version": None, + "domain": "fake_integration", + "is_fixable": False, + "is_persistent": True, + "issue_domain": None, + "issue_id": "test_issue", + "learn_more_url": None, + "severity": "warning", + "translation_key": "test_issue", + "translation_placeholders": None, + }, + ], "setup_times": {}, } diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index 0449f68263c..6998299c76f 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -162,7 +162,7 @@ async def test_dhcp_unique_id_assignment( """Test dhcp initialized flow with no unique id for matching entry.""" dhcp_data = DhcpServiceInfo( ip="2.3.4.5", - macaddress="11:22:33:44:55:66", + macaddress="112233445566", hostname="dsp-w215", ) result = await hass.config_entries.flow.async_init( @@ -177,7 +177,7 @@ async def test_dhcp_unique_id_assignment( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == CONF_DATA | {CONF_HOST: "2.3.4.5"} - assert result["result"].unique_id == "11:22:33:44:55:66" + assert result["result"].unique_id == "112233445566" async def test_dhcp_changed_ip( diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index 1a565345275..d9420afaa8c 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.components.dnsip.const import ( CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, + DEFAULT_HOSTNAME, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -379,3 +380,36 @@ async def test_options_error(hass: HomeAssistant, p_input: dict[str, str]) -> No assert result2["errors"] == {"resolver": "invalid_resolver"} if p_input[CONF_IPV6]: assert result2["errors"] == {"resolver_ipv6": "invalid_resolver"} + + +async def test_cannot_configure_options_for_myip(hass: HomeAssistant) -> None: + """Test options config flow aborts for default myip hostname.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="12345", + data={ + CONF_HOSTNAME: DEFAULT_HOSTNAME, + CONF_NAME: "myip", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::5", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_options" diff --git a/tests/components/downloader/conftest.py b/tests/components/downloader/conftest.py new file mode 100644 index 00000000000..3bb63455ccc --- /dev/null +++ b/tests/components/downloader/conftest.py @@ -0,0 +1,94 @@ +"""Provide common fixtures for downloader tests.""" + +import asyncio +from pathlib import Path + +import pytest +from requests_mock import Mocker + +from homeassistant.components.downloader.const import ( + CONF_DOWNLOAD_DIR, + DOMAIN, + DOWNLOAD_COMPLETED_EVENT, + DOWNLOAD_FAILED_EVENT, +) +from homeassistant.core import Event, HomeAssistant, callback + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the downloader integration for testing.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + download_dir: Path, +) -> MockConfigEntry: + """Return a mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DOWNLOAD_DIR: str(download_dir)}, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def download_dir(tmp_path: Path) -> Path: + """Return a download directory.""" + return tmp_path + + +@pytest.fixture(autouse=True) +def mock_download_request( + requests_mock: Mocker, + download_url: str, +) -> None: + """Mock the download request.""" + requests_mock.get(download_url, text="{'one': 1}") + + +@pytest.fixture +def download_url() -> str: + """Return a mock download URL.""" + return "http://example.com/file.txt" + + +@pytest.fixture +def download_completed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download completion.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download is completed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", download_set) + + return download_event + + +@pytest.fixture +def download_failed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download failure.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download has failed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", download_set) + + return download_event diff --git a/tests/components/downloader/test_init.py b/tests/components/downloader/test_init.py index e74eb376b39..fe001838afe 100644 --- a/tests/components/downloader/test_init.py +++ b/tests/components/downloader/test_init.py @@ -1,6 +1,8 @@ """Tests for the downloader component init.""" -from unittest.mock import patch +from pathlib import Path + +import pytest from homeassistant.components.downloader.const import ( CONF_DOWNLOAD_DIR, @@ -13,17 +15,57 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_initialization(hass: HomeAssistant) -> None: - """Test the initialization of the downloader component.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_DOWNLOAD_DIR: "/test_dir", - }, - ) - config_entry.add_to_hass(hass) - with patch("os.path.isdir", return_value=True): - assert await hass.config_entries.async_setup(config_entry.entry_id) +@pytest.fixture +def download_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + """Return a download directory.""" + if hasattr(request, "param"): + return tmp_path / request.param + return tmp_path + + +async def test_config_entry_setup( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Test config entry setup.""" + config_entry = setup_integration assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) assert config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_setup_relative_directory( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config entry setup with a relative download directory.""" + relative_directory = "downloads" + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_DOWNLOAD_DIR: relative_directory}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # The config entry will fail to set up since the directory does not exist. + # This is not relevant for this test. + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.data[CONF_DOWNLOAD_DIR] == hass.config.path( + relative_directory + ) + + +@pytest.mark.parametrize( + "download_dir", + [ + "not_existing_path", + ], + indirect=True, +) +async def test_config_entry_setup_not_existing_directory( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry setup without existing download directory.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert not hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/downloader/test_services.py b/tests/components/downloader/test_services.py new file mode 100644 index 00000000000..fbdc088021a --- /dev/null +++ b/tests/components/downloader/test_services.py @@ -0,0 +1,54 @@ +"""Test downloader services.""" + +import asyncio +from contextlib import AbstractContextManager, nullcontext as does_not_raise + +import pytest + +from homeassistant.components.downloader.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("subdir", "expected_result"), + [ + ("test", does_not_raise()), + ("test/path", does_not_raise()), + ("~test/path", pytest.raises(ServiceValidationError)), + ("~/../test/path", pytest.raises(ServiceValidationError)), + ("../test/path", pytest.raises(ServiceValidationError)), + (".../test/path", pytest.raises(ServiceValidationError)), + ("/test/path", pytest.raises(ServiceValidationError)), + ], +) +async def test_download_invalid_subdir( + hass: HomeAssistant, + download_completed: asyncio.Event, + download_failed: asyncio.Event, + download_url: str, + subdir: str, + expected_result: AbstractContextManager, +) -> None: + """Test service invalid subdirectory.""" + + async def call_service() -> None: + """Call the download service.""" + completed = hass.async_create_task(download_completed.wait()) + failed = hass.async_create_task(download_failed.wait()) + await hass.services.async_call( + DOMAIN, + "download_file", + { + "url": download_url, + "subdir": subdir, + "filename": "file.txt", + "overwrite": True, + }, + blocking=True, + ) + await asyncio.wait((completed, failed), return_when=asyncio.FIRST_COMPLETED) + + with expected_result: + await call_service() diff --git a/tests/components/drop_connect/snapshots/test_sensor.ambr b/tests/components/drop_connect/snapshots/test_sensor.ambr index a5c91dbe3e4..8389f92d8f9 100644 --- a/tests/components/drop_connect/snapshots/test_sensor.ambr +++ b/tests/components/drop_connect/snapshots/test_sensor.ambr @@ -356,7 +356,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -372,7 +372,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py index 86805fb456f..dc6cdc1b41a 100644 --- a/tests/components/dsmr_reader/test_definitions.py +++ b/tests/components/dsmr_reader/test_definitions.py @@ -4,15 +4,13 @@ import pytest from homeassistant.components.dsmr_reader.const import DOMAIN from homeassistant.components.dsmr_reader.definitions import ( - DSMRReaderSensorEntityDescription, dsmr_transform, tariff_transform, ) -from homeassistant.components.dsmr_reader.sensor import DSMRSensor from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, MockEntityPlatform, async_fire_mqtt_message +from tests.common import MockConfigEntry, async_fire_mqtt_message @pytest.mark.parametrize( @@ -71,7 +69,7 @@ async def test_entity_tariff(hass: HomeAssistant) -> None: assert hass.states.get(electricity_tariff).state == "low" -@pytest.mark.usefixtures("mqtt_mock") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mqtt_mock") async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: """Test the state attribute of DSMRReaderSensorEntityDescription when a dsmr transform is needed.""" config_entry = MockConfigEntry( @@ -85,17 +83,6 @@ async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Create the entity, since it's not by default - description = DSMRReaderSensorEntityDescription( - key="dsmr/meter-stats/dsmr_version", - name="version_test", - state=dsmr_transform, - ) - sensor = DSMRSensor(description, config_entry) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - await sensor.async_added_to_hass() - # Test dsmr version, if it's a digit async_fire_mqtt_message(hass, "dsmr/meter-stats/dsmr_version", "42") await hass.async_block_till_done() diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index e403c937394..0e847da73ad 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'E1234567890000000001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ecovacs', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'E1234567890000000001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index fcd043e10fa..c216c4c9e4a 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.e1234567890000000003_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'E1234567890000000003_battery_status', + 'unit_of_measurement': '%', + }) +# --- +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'E1234567890000000003 Battery', + 'icon': 'mdi:battery-unknown', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.e1234567890000000003_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_legacy_sensors[123][sensor.e1234567890000000003_filter_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -148,6 +199,7 @@ # --- # name: test_legacy_sensors[123][states] list([ + 'sensor.e1234567890000000003_battery', 'sensor.e1234567890000000003_main_brush_lifespan', 'sensor.e1234567890000000003_side_brush_lifespan', 'sensor.e1234567890000000003_filter_lifespan', diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index c0e5ce143c9..3115f1b4040 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -107,7 +107,7 @@ async def test_devices_in_dr( [ ("yna5x1", 26), ("5xu9h3", 25), - ("123", 1), + ("123", 2), ], ) async def test_all_entities_loaded( diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py index 4bfd45e9259..53c036c802d 100644 --- a/tests/components/eheimdigital/test_config_flow.py +++ b/tests/components/eheimdigital/test_config_flow.py @@ -7,12 +7,20 @@ from aiohttp import ClientConnectionError import pytest from homeassistant.components.eheimdigital.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + SOURCE_ZEROCONF, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .conftest import init_integration + +from tests.common import MockConfigEntry + ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.1"), ip_addresses=[ip_address("192.0.2.1")], @@ -210,3 +218,74 @@ async def test_abort(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> N assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +async def test_reconfigure( + hass: HomeAssistant, + eheimdigital_hub_mock: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error_value: str, +) -> None: + """Test reconfigure flow.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_value} + + eheimdigital_hub_mock.return_value.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert ( + mock_config_entry.unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_reconfigure_different_device( + hass: HomeAssistant, + eheimdigital_hub_mock: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + await init_integration(hass, mock_config_entry) + + # Simulate a different device + eheimdigital_hub_mock.return_value.main.mac_address = "00:00:00:00:00:02" + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py index c64997ee372..4b282338954 100644 --- a/tests/components/eheimdigital/test_init.py +++ b/tests/components/eheimdigital/test_init.py @@ -2,8 +2,9 @@ from unittest.mock import MagicMock -from eheimdigital.types import EheimDeviceType +from eheimdigital.types import EheimDeviceType, EheimDigitalClientError +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -54,3 +55,15 @@ async def test_remove_device( device_entry.id, mock_config_entry.entry_id ) assert response["success"] + + +async def test_entry_setup_error( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test errors on setting up the config entry.""" + + eheimdigital_hub_mock.return_value.connect.side_effect = EheimDigitalClientError() + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py index c6b2063ec0c..a25fd7cd872 100644 --- a/tests/components/eheimdigital/test_light.py +++ b/tests/components/eheimdigital/test_light.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl -from eheimdigital.types import EheimDeviceType +from eheimdigital.types import EheimDeviceType, EheimDigitalClientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -24,6 +24,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.color import value_to_brightness @@ -114,20 +115,34 @@ async def test_dynamic_new_devices( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.usefixtures("eheimdigital_hub_mock") async def test_turn_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + eheimdigital_hub_mock: MagicMock, classic_led_ctrl_mock: EheimDigitalClassicLEDControl, ) -> None: """Test turning off the light.""" await init_integration(hass, mock_config_entry) - await mock_config_entry.runtime_data._async_device_found( + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) await hass.async_block_till_done() + classic_led_ctrl_mock.hub.send_packet.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_1"}, + blocking=True, + ) + + assert exc_info.value.translation_key == "communication_error" + + classic_led_ctrl_mock.hub.send_packet.side_effect = None + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -140,9 +155,9 @@ async def test_turn_off( for call in classic_led_ctrl_mock.hub.mock_calls if call[0] == "send_packet" ] - assert len(calls) == 2 - assert calls[0][1][0].get("title") == "MAN_MODE" - assert calls[1][1][0]["currentValues"][1] == 0 + assert len(calls) == 3 + assert calls[1][1][0].get("title") == "MAN_MODE" + assert calls[2][1][0]["currentValues"][1] == 0 @pytest.mark.parametrize( @@ -169,6 +184,23 @@ async def test_turn_on_brightness( ) await hass.async_block_till_done() + classic_led_ctrl_mock.hub.send_packet.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_1", + ATTR_BRIGHTNESS: dim_input, + }, + blocking=True, + ) + + assert exc_info.value.translation_key == "communication_error" + + classic_led_ctrl_mock.hub.send_packet.side_effect = None + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -184,9 +216,9 @@ async def test_turn_on_brightness( for call in classic_led_ctrl_mock.hub.mock_calls if call[0] == "send_packet" ] - assert len(calls) == 2 - assert calls[0][1][0].get("title") == "MAN_MODE" - assert calls[1][1][0]["currentValues"][1] == expected_dim_value + assert len(calls) == 3 + assert calls[1][1][0].get("title") == "MAN_MODE" + assert calls[2][1][0]["currentValues"][1] == expected_dim_value async def test_turn_on_effect( diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index 1c261e2947a..c47017b88e9 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -28,7 +28,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: def _client_mock(): client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS + return client_mock @@ -44,6 +45,10 @@ def mock_async_client() -> Generator[AsyncMock]: "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", new=mock_async_client, ), + patch( + "homeassistant.components.elevenlabs.tts.AsyncElevenLabs", + new=mock_async_client, + ), ): yield mock_async_client @@ -52,8 +57,12 @@ def mock_async_client() -> Generator[AsyncMock]: def mock_async_client_api_error() -> Generator[AsyncMock]: """Override async ElevenLabs client with ApiError side effect.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ApiError - client_mock.voices.get_all.side_effect = ApiError + api_error = ApiError() + api_error.body = { + "detail": {"status": "invalid_api_key", "message": "API key is invalid"} + } + client_mock.models.list.side_effect = api_error + client_mock.voices.get_all.side_effect = api_error with ( patch( @@ -68,11 +77,51 @@ def mock_async_client_api_error() -> Generator[AsyncMock]: yield mock_async_client +@pytest.fixture +def mock_async_client_voices_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "voices_unauthorized", + "message": "API is unauthorized for voices", + } + } + client_mock.voices.get_all.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + +@pytest.fixture +def mock_async_client_models_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "models_unauthorized", + "message": "API is unauthorized for models", + } + } + client_mock.models.list.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + @pytest.fixture def mock_async_client_connect_error() -> Generator[AsyncMock]: """Override async ElevenLabs client.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ConnectError("Unknown") + client_mock.models.list.side_effect = ConnectError("Unknown") client_mock.voices.get_all.side_effect = ConnectError("Unknown") with ( patch( diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index 7eeb0a6eb46..eccd5d49d92 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -7,14 +7,12 @@ import pytest from homeassistant.components.elevenlabs.const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -101,6 +99,94 @@ async def test_invalid_api_key( mock_setup_entry.assert_called_once() +async def test_voices_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_voices_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + +async def test_models_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_models_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + async def test_options_flow_init( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -166,7 +252,6 @@ async def test_options_flow_voice_settings_default( assert mock_entry.options == { CONF_MODEL: "model1", CONF_VOICE: "voice1", - CONF_OPTIMIZE_LATENCY: DEFAULT_OPTIMIZE_LATENCY, CONF_SIMILARITY: DEFAULT_SIMILARITY, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index a63672cc85d..f25a03f2824 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -15,13 +15,11 @@ from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( ATTR_MODEL, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -44,6 +42,19 @@ from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +class FakeAudioGenerator: + """Mock audio generator for ElevenLabs TTS.""" + + def __aiter__(self): + """Mock async iterator for audio parts.""" + + async def _gen(): + yield b"audio-part-1" + yield b"audio-part-2" + + return _gen() + + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @@ -74,12 +85,6 @@ def mock_similarity(): return DEFAULT_SIMILARITY / 2 -@pytest.fixture -def mock_latency(): - """Mock latency.""" - return (DEFAULT_OPTIMIZE_LATENCY + 1) % 5 # 0, 1, 2, 3, 4 - - @pytest.fixture(name="setup") async def setup_fixture( hass: HomeAssistant, @@ -98,6 +103,7 @@ async def setup_fixture( raise RuntimeError("Invalid setup fixture") await hass.async_block_till_done() + return mock_async_client @@ -114,10 +120,9 @@ def config_options_fixture() -> dict[str, Any]: @pytest.fixture(name="config_options_voice") -def config_options_voice_fixture(mock_similarity, mock_latency) -> dict[str, Any]: +def config_options_voice_fixture(mock_similarity) -> dict[str, Any]: """Return config options.""" return { - CONF_OPTIMIZE_LATENCY: mock_latency, CONF_SIMILARITY: mock_similarity, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, @@ -144,7 +149,7 @@ async def mock_config_entry_setup( config_entry.add_to_hass(hass) client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS with patch( "homeassistant.components.elevenlabs.AsyncElevenLabs", return_value=client_mock ): @@ -217,7 +222,10 @@ async def test_tts_service_speak( ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=DEFAULT_SIMILARITY, @@ -240,12 +248,11 @@ async def test_tts_service_speak( voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice=voice_id, - model=model_id, + voice_id=voice_id, + model_id=model_id, voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -287,7 +294,9 @@ async def test_tts_service_speak_lang_config( ) -> None: """Test service call say with other langcodes in the config.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -302,12 +311,11 @@ async def test_tts_service_speak_lang_config( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -337,8 +345,10 @@ async def test_tts_service_speak_error( ) -> None: """Test service call say with http response 400.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() - tts_entity._client.generate.side_effect = ApiError + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + tts_entity._client.text_to_speech.convert.side_effect = ApiError await hass.services.async_call( tts.DOMAIN, @@ -353,12 +363,11 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -396,18 +405,18 @@ async def test_tts_service_speak_voice_settings( tts_service: str, service_data: dict[str, Any], mock_similarity: float, - mock_latency: int, ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=mock_similarity, style=DEFAULT_STYLE, use_speaker_boost=DEFAULT_USE_SPEAKER_BOOST, ) - assert tts_entity._latency == mock_latency await hass.services.async_call( tts.DOMAIN, @@ -422,12 +431,11 @@ async def test_tts_service_speak_voice_settings( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice_id="voice2", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -457,7 +465,9 @@ async def test_tts_service_speak_without_options( ) -> None: """Test service call say with http response 200.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -472,12 +482,11 @@ async def test_tts_service_speak_without_options( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - optimize_streaming_latency=0, + voice_id="voice1", voice_settings=VoiceSettings( stability=0.5, similarity_boost=0.75, style=0.0, use_speaker_boost=True ), - model="model1", + model_id="model1", ) diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 2f1c2107b52..85f9fadd2a0 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -70,7 +70,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -80,7 +79,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -156,7 +154,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -166,7 +163,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 16f20224079..5dbc21f62df 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -102,7 +102,6 @@ 'CN11A1A00001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -112,7 +111,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'CN11A1A00001', - 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, }) @@ -222,7 +220,6 @@ 'CN11A1A00001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -232,7 +229,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'CN11A1A00001', - 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, }) @@ -342,7 +338,6 @@ 'CN11A1A00001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -352,7 +347,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'CN11A1A00001', - 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, }) diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 3592e88f975..f53f8d223bd 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -77,7 +77,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -87,7 +86,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -173,7 +171,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -183,7 +180,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -269,7 +265,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -279,7 +274,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -362,7 +356,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -372,7 +365,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -458,7 +450,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -468,7 +459,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index f29c16d0cae..61235f17ece 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -69,7 +69,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -79,7 +78,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -154,7 +152,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -164,7 +161,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index 5355013bf94..548f374010e 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -1144,7 +1144,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: data=DhcpServiceInfo( hostname="any", ip=MOCK_IP_ADDRESS, - macaddress="00:00:00:00:00:00", + macaddress="000000000000", ), ) await hass.async_block_till_done() diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 100fb2bd879..c9c1eafc838 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -43,6 +43,8 @@ FLOW_RESULT = { SENSOR_NAME = "emoncms@1.1.1.1" +UNIQUE_ID = "123-53535292" + @pytest.fixture def config_entry() -> MockConfigEntry: @@ -65,7 +67,7 @@ def config_entry_unique_id() -> MockConfigEntry: domain=DOMAIN, title=SENSOR_NAME, data=FLOW_RESULT_SECOND_URL, - unique_id="123-53535292", + unique_id=UNIQUE_ID, ) @@ -121,5 +123,5 @@ async def emoncms_client() -> AsyncGenerator[AsyncMock]: ): client = mock_client.return_value client.async_request.return_value = {"success": True, "message": FEEDS} - client.async_get_uuid.return_value = "123-53535292" + client.async_get_uuid.return_value = UNIQUE_ID yield client diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index fa8ae7ce068..bbb994002ac 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -2,14 +2,22 @@ from unittest.mock import AsyncMock -from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN +import pytest + +from homeassistant.components.emoncms.const import ( + CONF_ONLY_INCLUDE_FEEDID, + DOMAIN, + SYNC_MODE, + SYNC_MODE_AUTO, + SYNC_MODE_MANUAL, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration -from .conftest import EMONCMS_FAILURE, SENSOR_NAME +from .conftest import EMONCMS_FAILURE, FLOW_RESULT, SENSOR_NAME, UNIQUE_ID from tests.common import MockConfigEntry @@ -19,12 +27,97 @@ USER_INPUT = { } -async def test_user_flow( +@pytest.mark.parametrize( + ("url", "api_key"), + [ + (USER_INPUT[CONF_URL], "regenerated_api_key"), + ("http://1.1.1.2", USER_INPUT[CONF_API_KEY]), + ], +) +async def test_reconfigure( + hass: HomeAssistant, + emoncms_client: AsyncMock, + url: str, + api_key: str, +) -> None: + """Test reconfigure flow.""" + new_input = { + CONF_URL: url, + CONF_API_KEY: api_key, + } + config_entry = MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=new_input, + unique_id=UNIQUE_ID, + ) + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + new_input, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == new_input + + +async def test_reconfigure_api_error( hass: HomeAssistant, - mock_setup_entry: AsyncMock, emoncms_client: AsyncMock, ) -> None: - """Test we get the user form.""" + """Test reconfigure flow with API error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=USER_INPUT, + unique_id=UNIQUE_ID, + ) + await setup_integration(hass, config_entry) + emoncms_client.async_request.return_value = EMONCMS_FAILURE + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "api_error"} + assert result["description_placeholders"]["details"] == "failure" + assert result["step_id"] == "reconfigure" + + +async def test_user_flow_failure( + hass: HomeAssistant, emoncms_client: AsyncMock +) -> None: + """Test emoncms failure when adding a new entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + emoncms_client.async_request.return_value = EMONCMS_FAILURE + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["errors"]["base"] == "api_error" + assert result["description_placeholders"]["details"] == "failure" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_flow_manual_mode( + hass: HomeAssistant, mock_setup_entry: AsyncMock, emoncms_client: AsyncMock +) -> None: + """Test we get the user forms and the entry in manual mode.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -33,11 +126,10 @@ async def test_user_flow( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + {**USER_INPUT, SYNC_MODE: SYNC_MODE_MANUAL}, ) assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_ONLY_INCLUDE_FEEDID: ["1"]}, @@ -46,16 +138,32 @@ async def test_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == SENSOR_NAME assert result["data"] == {**USER_INPUT, CONF_ONLY_INCLUDE_FEEDID: ["1"]} + # assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_auto_mode( + hass: HomeAssistant, mock_setup_entry: AsyncMock, emoncms_client: AsyncMock +) -> None: + """Test we get the user form and the entry in automatic mode.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**USER_INPUT, SYNC_MODE: SYNC_MODE_AUTO}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == SENSOR_NAME + assert result["data"] == { + **USER_INPUT, + CONF_ONLY_INCLUDE_FEEDID: FLOW_RESULT[CONF_ONLY_INCLUDE_FEEDID], + } assert len(mock_setup_entry.mock_calls) == 1 -CONFIG_ENTRY = { - CONF_API_KEY: "my_api_key", - CONF_ONLY_INCLUDE_FEEDID: ["1"], - CONF_URL: "http://1.1.1.1", -} - - async def test_options_flow( hass: HomeAssistant, emoncms_client: AsyncMock, @@ -80,13 +188,12 @@ async def test_options_flow( async def test_options_flow_failure( hass: HomeAssistant, - mock_setup_entry: AsyncMock, emoncms_client: AsyncMock, config_entry: MockConfigEntry, ) -> None: """Options flow - test failure.""" - emoncms_client.async_request.return_value = EMONCMS_FAILURE await setup_integration(hass, config_entry) + emoncms_client.async_request.return_value = EMONCMS_FAILURE result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() assert result["errors"]["base"] == "api_error" diff --git a/tests/components/emoncms_history/__init__.py b/tests/components/emoncms_history/__init__.py new file mode 100644 index 00000000000..0c60d27655b --- /dev/null +++ b/tests/components/emoncms_history/__init__.py @@ -0,0 +1 @@ +"""Tests for emoncms_history component.""" diff --git a/tests/components/emoncms_history/test_init.py b/tests/components/emoncms_history/test_init.py new file mode 100644 index 00000000000..c62252750b5 --- /dev/null +++ b/tests/components/emoncms_history/test_init.py @@ -0,0 +1,125 @@ +"""The tests for the emoncms_history init.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +import aiohttp +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import CONF_API_KEY, CONF_URL, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_setup_valid_config(hass: HomeAssistant) -> None: + """Test setting up the emoncms_history component with valid configuration.""" + config = { + "emoncms_history": { + CONF_API_KEY: "dummy", + CONF_URL: "https://emoncms.example", + "inputnode": 42, + "whitelist": ["sensor.temp"], + } + } + # Simulate a sensor + hass.states.async_set("sensor.temp", "23.4", {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "emoncms_history", config) + await hass.async_block_till_done() + + +async def test_setup_missing_config(hass: HomeAssistant) -> None: + """Test setting up the emoncms_history component with missing configuration.""" + config = {"emoncms_history": {"api_key": "dummy"}} + success = await async_setup_component(hass, "emoncms_history", config) + assert not success + + +@pytest.fixture +async def emoncms_client() -> AsyncGenerator[AsyncMock]: + """Mock pyemoncms client with successful responses.""" + with patch( + "homeassistant.components.emoncms_history.EmoncmsClient", autospec=True + ) as mock_client: + client = mock_client.return_value + client.async_input_post.return_value = '{"success": true}' + yield client + + +async def test_emoncms_send_data( + hass: HomeAssistant, + emoncms_client: AsyncMock, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sending data to Emoncms with and without success.""" + + config = { + "emoncms_history": { + "api_key": "dummy", + "url": "http://fake-url", + "inputnode": 42, + "whitelist": ["sensor.temp"], + } + } + + assert await async_setup_component(hass, "emoncms_history", config) + await hass.async_block_till_done() + + for state in None, "", STATE_UNAVAILABLE, STATE_UNKNOWN: + hass.states.async_set("sensor.temp", state, {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + assert emoncms_client.async_input_post.call_args is None + + hass.states.async_set("sensor.temp", "not_a_number", {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + emoncms_client.async_input_post.assert_not_called() + + hass.states.async_set("sensor.temp", "23.4", {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + emoncms_client.async_input_post.assert_called_once() + assert emoncms_client.async_input_post.return_value == '{"success": true}' + + _, kwargs = emoncms_client.async_input_post.call_args + assert kwargs["data"] == {"sensor.temp": 23.4} + assert kwargs["node"] == "42" + + emoncms_client.async_input_post.side_effect = aiohttp.ClientError( + "Connection refused" + ) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + assert any( + "Network error when sending data to Emoncms" in message + for message in caplog.text.splitlines() + ) + + emoncms_client.async_input_post.side_effect = ValueError("Invalid value format") + + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + assert any( + "Value error when preparing data for Emoncms" in message + for message in caplog.text.splitlines() + ) diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py index ec3f064dfe0..a05660519c9 100644 --- a/tests/components/emulated_roku/test_binding.py +++ b/tests/components/emulated_roku/test_binding.py @@ -15,7 +15,7 @@ from homeassistant.components.emulated_roku.binding import ( ROKU_COMMAND_LAUNCH, EmulatedRoku, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback async def test_events_fired_properly(hass: HomeAssistant) -> None: @@ -43,6 +43,7 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: return Mock(start=AsyncMock(), close=AsyncMock()) + @callback def listener(event: Event) -> None: if event.data[ATTR_SOURCE_NAME] == random_name: events.append(event) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a9a249a8498..b7ccbadbe1c 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -29,6 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import _WH_TO_CAL, _WH_TO_J from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done @@ -748,10 +749,12 @@ async def test_cost_sensor_price_entity_total_no_reset( @pytest.mark.parametrize( ("energy_unit", "factor"), [ + (UnitOfEnergy.MILLIWATT_HOUR, 1e6), (UnitOfEnergy.WATT_HOUR, 1000), (UnitOfEnergy.KILO_WATT_HOUR, 1), (UnitOfEnergy.MEGA_WATT_HOUR, 0.001), - (UnitOfEnergy.GIGA_JOULE, 0.001 * 3.6), + (UnitOfEnergy.GIGA_JOULE, _WH_TO_J / 1e6), + (UnitOfEnergy.CALORIE, _WH_TO_CAL * 1e3), ], ) async def test_cost_sensor_handle_energy_units( @@ -815,6 +818,7 @@ async def test_cost_sensor_handle_energy_units( @pytest.mark.parametrize( ("price_unit", "factor"), [ + (f"EUR/{UnitOfEnergy.MILLIWATT_HOUR}", 1e-6), (f"EUR/{UnitOfEnergy.WATT_HOUR}", 0.001), (f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", 1), (f"EUR/{UnitOfEnergy.MEGA_WATT_HOUR}", 1000), diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 6389ac0b372..9e7a2151b04 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -12,6 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component +ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy)) + +ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy)) + @pytest.fixture def mock_is_entity_recorded(): @@ -69,6 +73,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("state_class", "energy_unit", "extra"), [ + ("total_increasing", UnitOfEnergy.MILLIWATT_HOUR, {}), ("total_increasing", UnitOfEnergy.KILO_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.MEGA_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.WATT_HOUR, {}), @@ -76,6 +81,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: ("total", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("measurement", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("total_increasing", UnitOfEnergy.GIGA_JOULE, {}), + ("total_increasing", UnitOfEnergy.CALORIE, {}), ], ) async def test_validation( @@ -235,9 +241,7 @@ async def test_validation_device_consumption_entity_unexpected_unit( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.unexpected_unit", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -325,9 +329,7 @@ async def test_validation_solar( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.solar_production", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -378,9 +380,7 @@ async def test_validation_battery( ("sensor.battery_import", "beers"), ("sensor.battery_export", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, ] ], @@ -449,9 +449,7 @@ async def test_validation_grid( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -538,9 +536,7 @@ async def test_validation_grid_external_cost_compensation( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -710,9 +706,7 @@ async def test_validation_grid_auto_cost_entity_errors( { "type": "entity_unexpected_unit_energy_price", "affected_entities": {("sensor.grid_price_1", "$/Ws")}, - "translation_placeholders": { - "price_units": "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh" - }, + "translation_placeholders": {"price_units": ENERGY_PRICE_UNITS_STRING}, }, ), ], @@ -855,7 +849,7 @@ async def test_validation_gas( "type": "entity_unexpected_unit_gas", "affected_entities": {("sensor.gas_consumption_1", "beers")}, "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh", + "energy_units": ENERGY_UNITS_STRING, "gas_units": "CCF, ft³, m³, L", }, }, @@ -885,7 +879,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_price_2", "EUR/invalid")}, "translation_placeholders": { "price_units": ( - "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" + f"{ENERGY_PRICE_UNITS_STRING}, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" ) }, }, diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 7ad15f85ac2..9e94dab5a4c 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -9,6 +9,8 @@ import multidict from pyenphase import ( EnvoyACBPower, EnvoyBatteryAggregate, + EnvoyC6CC, + EnvoyCollar, EnvoyData, EnvoyEncharge, EnvoyEnchargeAggregate, @@ -260,6 +262,10 @@ def _load_json_2_encharge_enpower_data( ) if item := json_fixture["data"].get("battery_aggregate"): mocked_data.battery_aggregate = EnvoyBatteryAggregate(**item) + if item := json_fixture["data"].get("collar"): + mocked_data.collar = EnvoyCollar(**item) + if item := json_fixture["data"].get("c6cc"): + mocked_data.c6cc = EnvoyC6CC(**item) def _load_json_2_raw_data(mocked_data: EnvoyData, json_fixture: dict[str, Any]) -> None: diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 73af5af0e5d..e8e0fd8ac85 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -407,6 +407,35 @@ "type": "NONE" } }, + "collar": { + "admin_state": 88, + "admin_state_str": "ENCMN_MDE_ON_GRID", + "firmware_loaded_date": 1752939759, + "firmware_version": "3.0.6-D0", + "installed_date": 1752939759, + "last_report_date": 1752939759, + "communicating": true, + "mid_state": "close", + "grid_state": "on_grid", + "part_number": "865-00400-r22", + "serial_number": "482520020939", + "temperature": 42, + "temperature_unit": "C", + "control_error": 0, + "collar_state": "Installed" + }, + "c6cc": { + "admin_state": 82, + "admin_state_str": "ENCMN_C6_CC_READY", + "firmware_loaded_date": 1752945451, + "firmware_version": "0.1.20-D1", + "installed_date": 1752945451, + "last_report_date": 1752945451, + "communicating": true, + "part_number": "800-02403-r08", + "serial_number": "482523040549", + "dmir_version": "0.1.20-D1" + }, "inverters": { "1": { "serial_number": "1", diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index bbf35621c6c..18e7a9c9008 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -96,6 +96,104 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.c6_combiner_482523040549_communicating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communicating', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'communicating', + 'unique_id': '482523040549_communicating', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'C6 Combiner 482523040549 Communicating', + }), + 'context': , + 'entity_id': 'binary_sensor.c6_combiner_482523040549_communicating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.collar_482520020939_communicating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communicating', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'communicating', + 'unique_id': '482520020939_communicating', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Collar 482520020939 Communicating', + }), + 'context': , + 'entity_id': 'binary_sensor.collar_482520020939_communicating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.encharge_123456_communicating-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 7ad45ff51f3..ca6c502d3be 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -50,7 +50,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ @@ -298,7 +296,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -307,8 +304,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, - 'suggested_area': None, + 'serial_number': '1', 'sw_version': None, }), 'entities': list([ @@ -929,7 +925,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -939,7 +934,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ @@ -1177,7 +1171,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -1186,8 +1179,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, - 'suggested_area': None, + 'serial_number': '1', 'sw_version': None, }), 'entities': list([ @@ -1852,7 +1844,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -1862,7 +1853,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ @@ -2100,7 +2090,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -2109,8 +2098,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, - 'suggested_area': None, + 'serial_number': '1', 'sw_version': None, }), 'entities': list([ @@ -2796,7 +2784,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -2805,8 +2792,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, - 'suggested_area': None, + 'serial_number': '1', 'sw_version': None, }), 'entities': list([ @@ -3346,7 +3332,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -3356,7 +3341,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 4a9563ce906..00cb30fce09 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -13947,6 +13947,253 @@ 'state': 'unknown', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.c6_combiner_482523040549_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '482523040549_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'C6 Combiner 482523040549 Last reported', + }), + 'context': , + 'entity_id': 'sensor.c6_combiner_482523040549_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-07-19T17:17:31+00:00', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_grid_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid status', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_status', + 'unique_id': '482520020939_grid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 Grid status', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_grid_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on_grid', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '482520020939_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Collar 482520020939 Last reported', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-07-19T15:42:39+00:00', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_mid_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MID state', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mid_state', + 'unique_id': '482520020939_mid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 MID state', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_mid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'close', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '482520020939_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Collar 482520020939 Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index a738b31c183..2aa18c991a6 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -229,6 +229,45 @@ async def test_coordinator_token_refresh_error( assert entity_state.state == "116" +@respx.mock +@pytest.mark.freeze_time("2024-07-23 00:00:00+00:00") +async def test_coordinator_first_update_auth_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, +) -> None: + """Test coordinator update error handling.""" + current_token = encode( + # some time in future + payload={"name": "envoy", "exp": 1927314600}, + key="secret", + algorithm="HS256", + ) + + # mock envoy with expired token in config + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title="Envoy 1234", + unique_id="1234", + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: current_token, + }, + ) + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", + token=current_token, + envoy_serial="1234", + cloud_username="test_username", + cloud_password="test_password", + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + await setup_integration(hass, entry, ConfigEntryState.SETUP_ERROR) + + async def test_config_no_unique_id( hass: HomeAssistant, mock_envoy: AsyncMock, @@ -470,7 +509,7 @@ async def test_coordinator_interface_information( # verify first time add of mac to connections is in log assert "added connection" in caplog.text - # trigger integration reload by changing options + # update options and reload hass.config_entries.async_update_entry( config_entry, options={ @@ -478,6 +517,7 @@ async def test_coordinator_interface_information( OPTION_DISABLE_KEEP_ALIVE: True, }, ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/eq3btsmart/conftest.py b/tests/components/eq3btsmart/conftest.py index 92f1be29b70..ce55a1fccbd 100644 --- a/tests/components/eq3btsmart/conftest.py +++ b/tests/components/eq3btsmart/conftest.py @@ -28,7 +28,7 @@ def fake_service_info(): source="local", connectable=False, time=0, - device=generate_ble_device(address=MAC, name="CC-RT-BLE", rssi=0), + device=generate_ble_device(address=MAC, name="CC-RT-BLE"), advertisement=AdvertisementData( local_name="CC-RT-BLE", manufacturer_data={}, diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 554f1725f4b..86db1fc3109 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -55,4 +55,4 @@ async def test_client_usage_while_not_connected(client_data: ESPHomeClientData) with pytest.raises( BleakError, match=f"{ESP_NAME}.*{ESP_MAC_ADDRESS}.*not connected" ): - assert await client.write_gatt_char("test", b"test") is False + assert await client.write_gatt_char("test", b"test", False) is False diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 9de97bac3eb..f9383d3b4f7 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -517,9 +517,30 @@ async def _mock_generic_device_entry( mock_client.list_entities_services = AsyncMock( return_value=mock_list_entities_services ) - mock_client.subscribe_states = _subscribe_states - mock_client.subscribe_service_calls = _subscribe_service_calls - mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(mock_device.device_info, *mock_list_entities_services) + ) + + def _subscribe_home_assistant_states_and_services( + *, + on_state: Callable[[EntityState], None], + on_service_call: Callable[[HomeassistantServiceCall], None], + on_state_sub: Callable[[str, str | None], None], + on_state_request: Callable[[str, str | None], None], + ) -> None: + """Subscribe to states and service calls.""" + mock_device.set_state_callback(on_state) + mock_device.set_service_call_callback(on_service_call) + mock_device.set_home_assistant_state_subscription_callback( + on_state_sub, on_state_request + ) + # Set the initial states + for state in states: + on_state(state) + + mock_client.subscribe_home_assistant_states_and_services = ( + _subscribe_home_assistant_states_and_services + ) mock_client.subscribe_logs = _subscribe_logs try_connect_done = Event() diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index dac224c802f..6b7a1c64c9f 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -82,6 +82,7 @@ 'minor': 99, }), 'device_info': dict({ + 'api_encryption_supported': False, 'area': dict({ 'area_id': 0, 'name': '', diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index 5a90086eac0..ff16731b44e 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -40,7 +40,6 @@ async def test_generic_alarm_control_panel_requires_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -59,7 +58,7 @@ async def test_generic_alarm_control_panel_requires_code( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") assert state is not None assert state.state == AlarmControlPanelState.ARMED_AWAY @@ -67,13 +66,13 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_AWAY, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_AWAY, "1234")] + [call(1, AlarmControlPanelCommand.ARM_AWAY, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -81,13 +80,13 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, "1234")] + [call(1, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -95,13 +94,13 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_HOME, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_HOME, "1234")] + [call(1, AlarmControlPanelCommand.ARM_HOME, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -109,13 +108,13 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_NIGHT, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_NIGHT, "1234")] + [call(1, AlarmControlPanelCommand.ARM_NIGHT, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -123,13 +122,13 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_VACATION, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_VACATION, "1234")] + [call(1, AlarmControlPanelCommand.ARM_VACATION, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -137,13 +136,13 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_TRIGGER, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.TRIGGER, "1234")] + [call(1, AlarmControlPanelCommand.TRIGGER, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -151,13 +150,13 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.DISARM, "1234")] + [call(1, AlarmControlPanelCommand.DISARM, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -173,7 +172,6 @@ async def test_generic_alarm_control_panel_no_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -192,18 +190,18 @@ async def test_generic_alarm_control_panel_no_code( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") assert state is not None assert state.state == AlarmControlPanelState.ARMED_AWAY await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, - {ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel"}, + {ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel"}, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.DISARM, None)] + [call(1, AlarmControlPanelCommand.DISARM, None, device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -219,7 +217,6 @@ async def test_generic_alarm_control_panel_missing_state( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -238,6 +235,6 @@ async def test_generic_alarm_control_panel_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 3acdc1f2029..2fdf53dc5ea 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -953,7 +953,6 @@ async def test_tts_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1020,7 +1019,6 @@ async def test_tts_minimal_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1156,7 +1154,6 @@ async def test_announce_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1437,7 +1434,6 @@ async def test_start_conversation_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1776,6 +1772,78 @@ async def test_get_set_configuration( assert satellite.async_get_configuration() == updated_config +async def test_intent_progress_optimization( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that intent progress events are only sent when early TTS streaming is available.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Test that intent progress without tts_start_streaming is not sent + mock_client.send_voice_assistant_event.reset_mock() + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"some_other_key": "value"}, + ) + ) + mock_client.send_voice_assistant_event.assert_not_called() + + # Test that intent progress with tts_start_streaming=False is not sent + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": False}, + ) + ) + mock_client.send_voice_assistant_event.assert_not_called() + + # Test that intent progress with tts_start_streaming=True is sent + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": True}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS, + {"tts_start_streaming": "1"}, + ) + + # Test that intent progress with tts_start_streaming as string "1" is sent + mock_client.send_voice_assistant_event.reset_mock() + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": "1"}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS, + {"tts_start_streaming": "1"}, + ) + + # Test that intent progress with no data is *not* sent + mock_client.send_voice_assistant_event.reset_mock() + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data=None, + ) + ) + mock_client.send_voice_assistant_event.assert_not_called() + + async def test_wake_word_select( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index fee285ea312..0e3bcc5a115 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,6 +1,6 @@ """Test ESPHome binary sensors.""" -from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState +from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState, SubDeviceInfo import pytest from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN @@ -24,7 +24,6 @@ async def test_binary_sensor_generic_entity( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] esphome_state, hass_state = binary_state @@ -36,7 +35,7 @@ async def test_binary_sensor_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == hass_state @@ -52,7 +51,6 @@ async def test_status_binary_sensor( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] @@ -64,7 +62,7 @@ async def test_status_binary_sensor( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON @@ -80,7 +78,6 @@ async def test_binary_sensor_missing_state( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [BinarySensorState(key=1, state=True, missing_state=True)] @@ -91,7 +88,7 @@ async def test_binary_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -107,7 +104,6 @@ async def test_binary_sensor_has_state_false( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -118,12 +114,162 @@ async def test_binary_sensor_has_state_false( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNKNOWN mock_device.set_state(BinarySensorState(key=1, state=True, missing_state=False)) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON + + +async def test_binary_sensors_same_key_different_device_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test binary sensors with same key but different device_id.""" + # Create sub-devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device 1", area_id=0), + SubDeviceInfo(device_id=22222222, name="Sub Device 2", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Both sub-devices have a binary sensor with key=1 + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Motion", + device_id=11111111, + ), + BinarySensorInfo( + object_id="sensor", + key=1, + name="Motion", + device_id=22222222, + ), + ] + + # States for both sensors with same key but different device_id + states = [ + BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111), + BinarySensorState(key=1, state=False, missing_state=False, device_id=22222222), + ] + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify both entities exist and have correct states + state1 = hass.states.get("binary_sensor.sub_device_1_motion") + assert state1 is not None + assert state1.state == STATE_ON + + state2 = hass.states.get("binary_sensor.sub_device_2_motion") + assert state2 is not None + assert state2.state == STATE_OFF + + # Update states to verify they update independently + mock_device.set_state( + BinarySensorState(key=1, state=False, missing_state=False, device_id=11111111) + ) + await hass.async_block_till_done() + + state1 = hass.states.get("binary_sensor.sub_device_1_motion") + assert state1.state == STATE_OFF + + # Sub device 2 should remain unchanged + state2 = hass.states.get("binary_sensor.sub_device_2_motion") + assert state2.state == STATE_OFF + + # Update sub device 2 + mock_device.set_state( + BinarySensorState(key=1, state=True, missing_state=False, device_id=22222222) + ) + await hass.async_block_till_done() + + state2 = hass.states.get("binary_sensor.sub_device_2_motion") + assert state2.state == STATE_ON + + # Sub device 1 should remain unchanged + state1 = hass.states.get("binary_sensor.sub_device_1_motion") + assert state1.state == STATE_OFF + + +async def test_binary_sensor_main_and_sub_device_same_key( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test binary sensor on main device and sub-device with same key.""" + # Create sub-device + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Main device and sub-device both have a binary sensor with key=1 + entity_info = [ + BinarySensorInfo( + object_id="main_sensor", + key=1, + name="Main Sensor", + device_id=0, # Main device + ), + BinarySensorInfo( + object_id="sub_sensor", + key=1, + name="Sub Sensor", + device_id=11111111, + ), + ] + + # States for both sensors + states = [ + BinarySensorState(key=1, state=True, missing_state=False, device_id=0), + BinarySensorState(key=1, state=False, missing_state=False, device_id=11111111), + ] + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify both entities exist + main_state = hass.states.get("binary_sensor.test_main_sensor") + assert main_state is not None + assert main_state.state == STATE_ON + + sub_state = hass.states.get("binary_sensor.sub_device_sub_sensor") + assert sub_state is not None + assert sub_state.state == STATE_OFF + + # Update main device sensor + mock_device.set_state( + BinarySensorState(key=1, state=False, missing_state=False, device_id=0) + ) + await hass.async_block_till_done() + + main_state = hass.states.get("binary_sensor.test_main_sensor") + assert main_state.state == STATE_OFF + + # Sub device sensor should remain unchanged + sub_state = hass.states.get("binary_sensor.sub_device_sub_sensor") + assert sub_state.state == STATE_OFF diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index 8c120949caa..b85dd04e6b7 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -18,7 +18,6 @@ async def test_button_generic_entity( object_id="mybutton", key=1, name="my button", - unique_id="my_button", ) ] states = [] @@ -29,22 +28,22 @@ async def test_button_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("button.test_mybutton") + state = hass.states.get("button.test_my_button") assert state is not None assert state.state == STATE_UNKNOWN await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_mybutton"}, + {ATTR_ENTITY_ID: "button.test_my_button"}, blocking=True, ) - mock_client.button_command.assert_has_calls([call(1)]) - state = hass.states.get("button.test_mybutton") + mock_client.button_command.assert_has_calls([call(1, device_id=0)]) + state = hass.states.get("button.test_my_button") assert state is not None assert state.state != STATE_UNKNOWN await mock_device.mock_disconnect(False) - state = hass.states.get("button.test_mybutton") + state = hass.states.get("button.test_my_button") assert state is not None assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index b03d2bb7983..2f3966fe1f6 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -30,7 +30,6 @@ async def test_camera_single_image( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -41,7 +40,7 @@ async def test_camera_single_image( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -51,9 +50,9 @@ async def test_camera_single_image( mock_client.request_single_image = _mock_camera_image client = await hass_client() - resp = await client.get("/api/camera_proxy/camera.test_mycamera") + resp = await client.get("/api/camera_proxy/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -75,7 +74,6 @@ async def test_camera_single_image_unavailable_before_requested( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -86,15 +84,15 @@ async def test_camera_single_image_unavailable_before_requested( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE await mock_device.mock_disconnect(False) client = await hass_client() - resp = await client.get("/api/camera_proxy/camera.test_mycamera") + resp = await client.get("/api/camera_proxy/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -113,7 +111,6 @@ async def test_camera_single_image_unavailable_during_request( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -124,7 +121,7 @@ async def test_camera_single_image_unavailable_during_request( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -134,9 +131,9 @@ async def test_camera_single_image_unavailable_during_request( mock_client.request_single_image = _mock_camera_image client = await hass_client() - resp = await client.get("/api/camera_proxy/camera.test_mycamera") + resp = await client.get("/api/camera_proxy/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -155,7 +152,6 @@ async def test_camera_stream( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -166,7 +162,7 @@ async def test_camera_stream( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE remaining_responses = 3 @@ -182,9 +178,9 @@ async def test_camera_stream( mock_client.request_single_image = _mock_camera_image client = await hass_client() - resp = await client.get("/api/camera_proxy_stream/camera.test_mycamera") + resp = await client.get("/api/camera_proxy_stream/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -212,7 +208,6 @@ async def test_camera_stream_unavailable( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -223,16 +218,16 @@ async def test_camera_stream_unavailable( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE await mock_device.mock_disconnect(False) client = await hass_client() - await client.get("/api/camera_proxy_stream/camera.test_mycamera") + await client.get("/api/camera_proxy_stream/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -249,7 +244,6 @@ async def test_camera_stream_with_disconnection( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -260,7 +254,7 @@ async def test_camera_stream_with_disconnection( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE remaining_responses = 3 @@ -278,8 +272,8 @@ async def test_camera_stream_with_disconnection( mock_client.request_single_image = _mock_camera_image client = await hass_client() - await client.get("/api/camera_proxy_stream/camera.test_mycamera") + await client.get("/api/camera_proxy_stream/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index dd42ee97029..c574764e3c9 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -58,7 +58,6 @@ async def test_climate_entity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_action=True, visual_min_temperature=10.0, @@ -83,17 +82,19 @@ async def test_climate_entity( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_temperature=25.0, device_id=0)] + ) mock_client.climate_command.reset_mock() @@ -108,7 +109,6 @@ async def test_climate_entity_with_step_and_two_point( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, visual_target_temperature_step=2, @@ -137,7 +137,7 @@ async def test_climate_entity_with_step_and_two_point( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL @@ -145,7 +145,7 @@ async def test_climate_entity_with_step_and_two_point( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, blocking=True, ) @@ -153,7 +153,7 @@ async def test_climate_entity_with_step_and_two_point( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TARGET_TEMP_LOW: 20, ATTR_TARGET_TEMP_HIGH: 30, @@ -167,6 +167,7 @@ async def test_climate_entity_with_step_and_two_point( mode=ClimateMode.AUTO, target_temperature_low=20.0, target_temperature_high=30.0, + device_id=0, ) ] ) @@ -184,7 +185,6 @@ async def test_climate_entity_with_step_and_target_temp( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -217,7 +217,7 @@ async def test_climate_entity_with_step_and_target_temp( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL @@ -225,14 +225,14 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TEMPERATURE: 25, }, blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, mode=ClimateMode.AUTO, target_temperature=25.0)] + [call(key=1, mode=ClimateMode.AUTO, target_temperature=25.0, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -241,7 +241,7 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TARGET_TEMP_LOW: 20, ATTR_TARGET_TEMP_HIGH: 30, @@ -253,7 +253,7 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, @@ -263,6 +263,7 @@ async def test_climate_entity_with_step_and_target_temp( call( key=1, mode=ClimateMode.HEAT, + device_id=0, ) ] ) @@ -271,7 +272,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_PRESET_MODE: "away"}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "away"}, blocking=True, ) mock_client.climate_command.assert_has_calls( @@ -279,6 +280,7 @@ async def test_climate_entity_with_step_and_target_temp( call( key=1, preset=ClimatePreset.AWAY, + device_id=0, ) ] ) @@ -287,40 +289,44 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_PRESET_MODE: "preset1"}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, custom_preset="preset1")]) - mock_client.climate_command.reset_mock() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_FAN_MODE: FAN_HIGH}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "preset1"}, blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, fan_mode=ClimateFanMode.HIGH)] + [call(key=1, custom_preset="preset1", device_id=0)] ) mock_client.climate_command.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_FAN_MODE: "fan2"}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: FAN_HIGH}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, custom_fan_mode="fan2")]) + mock_client.climate_command.assert_has_calls( + [call(key=1, fan_mode=ClimateFanMode.HIGH, device_id=0)] + ) + mock_client.climate_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: "fan2"}, + blocking=True, + ) + mock_client.climate_command.assert_has_calls( + [call(key=1, custom_fan_mode="fan2", device_id=0)] + ) mock_client.climate_command.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_SWING_MODE: SWING_BOTH}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_SWING_MODE: SWING_BOTH}, blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, swing_mode=ClimateSwingMode.BOTH)] + [call(key=1, swing_mode=ClimateSwingMode.BOTH, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -336,7 +342,6 @@ async def test_climate_entity_with_humidity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -368,7 +373,7 @@ async def test_climate_entity_with_humidity( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes @@ -380,10 +385,12 @@ async def test_climate_entity_with_humidity( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HUMIDITY: 23}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HUMIDITY: 23}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, target_humidity=23)]) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_humidity=23, device_id=0)] + ) mock_client.climate_command.reset_mock() @@ -398,7 +405,6 @@ async def test_climate_entity_with_inf_value( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -430,7 +436,7 @@ async def test_climate_entity_with_inf_value( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes @@ -454,7 +460,6 @@ async def test_climate_entity_attributes( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -492,7 +497,7 @@ async def test_climate_entity_attributes( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL assert state.attributes == snapshot(name="climate-entity-attributes") @@ -509,7 +514,6 @@ async def test_climate_entity_attribute_current_temperature_unsupported( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=False, ) ] @@ -526,6 +530,6 @@ async def test_climate_entity_attribute_current_temperature_unsupported( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 3f0148262e4..1bedc6d79f8 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -27,6 +27,9 @@ from homeassistant.components.esphome.const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) +from homeassistant.components.esphome.encryption_key_storage import ( + ENCRYPTION_KEY_STORAGE_KEY, +) from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant @@ -41,6 +44,118 @@ from .conftest import MockGenericDeviceEntryType from tests.common import MockConfigEntry + +async def test_retrieve_encryption_key_from_storage_with_device_mac( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], +) -> None: + """Test key successfully retrieved from storage.""" + + # Mock the encryption key storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {"11:22:33:44:55:aa": VALID_NOISE_PSK}}, + } + + mock_client.device_info.side_effect = [ + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test", "11:22:33:44:55:AA"), + DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ), + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + + assert mock_client.noise_psk == VALID_NOISE_PSK + + +async def test_reauth_fixed_from_from_storage( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], +) -> None: + """Test reauth fixed automatically via storage.""" + + # Mock the encryption key storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {"11:22:33:44:55:aa": VALID_NOISE_PSK}}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.ABORT, result + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +async def test_retrieve_encryption_key_from_storage_no_key_found( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test _retrieve_encryption_key_from_storage when no key is found.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "reauth_confirm" + assert CONF_NOISE_PSK not in entry.data + + INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM=" WRONG_NOISE_PSK = "GP+ciK+nVfTQ/gcz6uOdS+oKEdJgesU+jeu8Ssj2how=" @@ -930,8 +1045,11 @@ async def test_encryption_key_valid_psk( assert result["step_id"] == "encryption_key" assert result["description_placeholders"] == {"name": "ESPHome"} - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + device_info = DeviceInfo(uses_password=False, name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1248,10 +1366,13 @@ async def test_reauth_confirm_invalid( assert result["errors"] assert result["errors"]["base"] == "invalid_psk" - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, name="test", mac_address="11:22:33:44:55:aa" - ) + device_info = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1289,10 +1410,13 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["errors"] assert result["errors"]["base"] == "invalid_psk" - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, name="test", mac_address="11:22:33:44:55:aa" - ) + device_info = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1345,8 +1469,11 @@ async def test_discovery_dhcp_updates_host( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(name="test8266", mac_address="1122334455aa") + device_info = DeviceInfo(name="test8266", mac_address="1122334455aa") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) service_info = DhcpServiceInfo( @@ -1381,8 +1508,11 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(name="test8266", mac_address="1122334455ff") + device_info = DeviceInfo(name="test8266", mac_address="1122334455ff") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) service_info = DhcpServiceInfo( @@ -1487,7 +1617,12 @@ async def test_discovery_dhcp_no_changes( ) entry.add_to_hass(hass) - mock_client.device_info = AsyncMock(return_value=DeviceInfo(name="test8266")) + device_info = DeviceInfo(name="test8266") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) + ) service_info = DhcpServiceInfo( ip="192.168.43.183", @@ -1919,12 +2054,15 @@ async def test_user_flow_name_conflict_migrate( unique_id="11:22:33:44:55:cc", ) existing_entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, - name="test", - mac_address="11:22:33:44:55:AA", - ) + device_info = DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_init( @@ -1969,12 +2107,15 @@ async def test_user_flow_name_conflict_overwrite( unique_id="11:22:33:44:55:cc", ) existing_entry.add_to_hass(hass) - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - uses_password=False, - name="test", - mac_address="11:22:33:44:55:AA", - ) + device_info = DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) result = await hass.config_entries.flow.async_init( @@ -2370,3 +2511,36 @@ async def test_reconfig_name_conflict_overwrite( ) is None ) + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_no_probe_same_host_port_none( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test dhcp discovery does not probe when host matches and port is None.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + # DHCP discovery with same MAC and host (WiFi device) + service_info = DhcpServiceInfo( + ip="192.168.43.183", + hostname="test8266", + macaddress="11:22:33:44:55:aa", # Same MAC as configured + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Verify device_info was NOT called (no probing) + mock_client.device_info.assert_not_called() + + # Host should remain unchanged + assert entry.data[CONF_HOST] == "192.168.43.183" diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 2ea789e9cc1..d7b92e490fe 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -41,7 +41,6 @@ async def test_cover_entity( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=True, supports_tilt=True, supports_stop=True, @@ -62,7 +61,7 @@ async def test_cover_entity( user_service=user_service, states=states, ) - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.OPENING assert state.attributes[ATTR_CURRENT_POSITION] == 50 @@ -71,71 +70,71 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_mycover", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_POSITION: 50}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=0.5, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.cover_command.assert_has_calls([call(key=1, stop=True, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: "cover.test_mycover", ATTR_TILT_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_TILT_POSITION: 50}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5, device_id=0)]) mock_client.cover_command.reset_mock() mock_device.set_state( ESPHomeCoverState(key=1, position=0.0, current_operation=CoverOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.CLOSED @@ -145,7 +144,7 @@ async def test_cover_entity( ) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.CLOSING @@ -153,7 +152,7 @@ async def test_cover_entity( ESPHomeCoverState(key=1, position=1.0, current_operation=CoverOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.OPEN @@ -169,7 +168,6 @@ async def test_cover_entity_without_position( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=False, supports_tilt=False, supports_stop=False, @@ -190,7 +188,7 @@ async def test_cover_entity_without_position( user_service=user_service, states=states, ) - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.OPENING assert ATTR_CURRENT_TILT_POSITION not in state.attributes diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 4bf291c50f5..9e555eb98c2 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -26,7 +26,6 @@ async def test_generic_date_entity( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, year=2024, month=12, day=31)] @@ -37,17 +36,17 @@ async def test_generic_date_entity( user_service=user_service, states=states, ) - state = hass.states.get("date.test_mydate") + state = hass.states.get("date.test_my_date") assert state is not None assert state.state == "2024-12-31" await hass.services.async_call( DATE_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "date.test_mydate", ATTR_DATE: "1999-01-01"}, + {ATTR_ENTITY_ID: "date.test_my_date", ATTR_DATE: "1999-01-01"}, blocking=True, ) - mock_client.date_command.assert_has_calls([call(1, 1999, 1, 1)]) + mock_client.date_command.assert_has_calls([call(1, 1999, 1, 1, device_id=0)]) mock_client.date_command.reset_mock() @@ -62,7 +61,6 @@ async def test_generic_date_missing_state( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, missing_state=True)] @@ -73,6 +71,6 @@ async def test_generic_date_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("date.test_mydate") + state = hass.states.get("date.test_my_date") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 1ccb101f581..940fae5cfef 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -26,7 +26,6 @@ async def test_generic_datetime_entity( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, epoch_seconds=1713270896)] @@ -37,7 +36,7 @@ async def test_generic_datetime_entity( user_service=user_service, states=states, ) - state = hass.states.get("datetime.test_mydatetime") + state = hass.states.get("datetime.test_my_datetime") assert state is not None assert state.state == "2024-04-16T12:34:56+00:00" @@ -45,12 +44,12 @@ async def test_generic_datetime_entity( DATETIME_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: "datetime.test_mydatetime", + ATTR_ENTITY_ID: "datetime.test_my_datetime", ATTR_DATETIME: "2000-01-01T01:23:45+00:00", }, blocking=True, ) - mock_client.datetime_command.assert_has_calls([call(1, 946689825)]) + mock_client.datetime_command.assert_has_calls([call(1, 946689825, device_id=0)]) mock_client.datetime_command.reset_mock() @@ -65,7 +64,6 @@ async def test_generic_datetime_missing_state( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, missing_state=True)] @@ -76,6 +74,6 @@ async def test_generic_datetime_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("datetime.test_mydatetime") + state = hass.states.get("datetime.test_my_datetime") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2653df57adb..ebfe15d562f 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -124,6 +124,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "api_encryption_supported": False, "area": {"area_id": 0, "name": ""}, "areas": [], "bluetooth_mac_address": "**REDACTED**", diff --git a/tests/components/esphome/test_dynamic_encryption.py b/tests/components/esphome/test_dynamic_encryption.py new file mode 100644 index 00000000000..cbdcc35aea2 --- /dev/null +++ b/tests/components/esphome/test_dynamic_encryption.py @@ -0,0 +1,102 @@ +"""Tests for ESPHome dynamic encryption key generation.""" + +from __future__ import annotations + +import base64 + +from homeassistant.components.esphome.encryption_key_storage import ( + ESPHomeEncryptionKeyStorage, + async_get_encryption_key_storage, +) +from homeassistant.core import HomeAssistant + + +async def test_dynamic_encryption_key_generation_mock(hass: HomeAssistant) -> None: + """Test that encryption key generation works with mocked storage.""" + storage = await async_get_encryption_key_storage(hass) + + # Store a key + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"test_key_32_bytes_long_exactly!").decode() + + await storage.async_store_key(mac_address, test_key) + + # Retrieve a key + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == test_key + + +async def test_encryption_key_storage_remove_key(hass: HomeAssistant) -> None: + """Test ESPHomeEncryptionKeyStorage async_remove_key method.""" + # Create storage instance + storage = ESPHomeEncryptionKeyStorage(hass) + + # Test removing a key that exists + mac_address = "11:22:33:44:55:aa" + test_key = "test_encryption_key_32_bytes_long" + + # First store a key + await storage.async_store_key(mac_address, test_key) + + # Verify key exists + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == test_key + + # Remove the key + await storage.async_remove_key(mac_address) + + # Verify key no longer exists + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key is None + + # Test removing a key that doesn't exist (should not raise an error) + non_existent_mac = "aa:bb:cc:dd:ee:ff" + await storage.async_remove_key(non_existent_mac) # Should not raise + + # Test case insensitive removal + upper_mac = "22:33:44:55:66:77" + await storage.async_store_key(upper_mac, test_key) + + # Remove using lowercase MAC address + await storage.async_remove_key(upper_mac.lower()) + + # Verify key was removed + retrieved_key = await storage.async_get_key(upper_mac) + assert retrieved_key is None + + +async def test_encryption_key_basic_storage( + hass: HomeAssistant, +) -> None: + """Test basic encryption key storage functionality.""" + storage = await async_get_encryption_key_storage(hass) + mac_address = "11:22:33:44:55:aa" + key = "test_encryption_key_32_bytes_long" + + # Store key + await storage.async_store_key(mac_address, key) + + # Retrieve key + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == key + + +async def test_retrieve_key_from_storage( + hass: HomeAssistant, +) -> None: + """Test config flow can retrieve encryption key from storage for new device.""" + # Test that the encryption key storage integration works with config flow + storage = await async_get_encryption_key_storage(hass) + mac_address = "11:22:33:44:55:aa" + stored_key = "test_encryption_key_32_bytes_long" + + # Store encryption key for a device + await storage.async_store_key(mac_address, stored_key) + + # Verify the key can be retrieved (simulating config flow behavior) + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == stored_key + + # Test case insensitive retrieval (since config flows might use different case) + retrieved_key_upper = await storage.async_get_key(mac_address.upper()) + assert retrieved_key_upper == stored_key diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 9dcfe73b898..8f2d7c33575 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( DeviceInfo, SensorInfo, SensorState, + SubDeviceInfo, build_unique_id, ) import pytest @@ -27,10 +28,14 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_state_change_event -from .conftest import MockESPHomeDevice, MockESPHomeDeviceType +from .conftest import ( + MockESPHomeDevice, + MockESPHomeDeviceType, + MockGenericDeviceEntryType, +) async def test_entities_removed( @@ -46,13 +51,11 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -67,10 +70,10 @@ async def test_entities_removed( entry = mock_device.entry entry_id = entry.entry_id storage_key = f"esphome.{entry_id}" - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.state == STATE_ON @@ -79,13 +82,13 @@ async def test_entities_removed( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.attributes[ATTR_RESTORED] is True - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None assert state.attributes[ATTR_RESTORED] is True @@ -95,7 +98,6 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -108,13 +110,13 @@ async def test_entities_removed( entry=entry, ) assert mock_device.entry.entry_id == entry_id - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is None await hass.config_entries.async_unload(entry.entry_id) @@ -135,13 +137,11 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -156,15 +156,15 @@ async def test_entities_removed_after_reload( entry = mock_device.entry entry_id = entry.entry_id storage_key = f"esphome.{entry_id}" - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.state == STATE_ON reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None @@ -173,15 +173,15 @@ async def test_entities_removed_after_reload( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.attributes[ATTR_RESTORED] is True - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.attributes[ATTR_RESTORED] is True reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None @@ -190,14 +190,14 @@ async def test_entities_removed_after_reload( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert ATTR_RESTORED not in state.attributes - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert ATTR_RESTORED not in state.attributes reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None @@ -209,12 +209,14 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] mock_device.client.list_entities_services = AsyncMock( return_value=(entity_info, []) ) + mock_device.client.device_info_and_list_entities = AsyncMock( + return_value=(mock_device.device_info, entity_info, []) + ) assert await hass.config_entries.async_setup(entry.entry_id) on_future = hass.loop.create_future() @@ -225,23 +227,23 @@ async def test_entities_removed_after_reload( on_future.set_result(None) async_track_state_change_event( - hass, ["binary_sensor.test_mybinary_sensor"], _async_wait_for_on + hass, ["binary_sensor.test_my_binary_sensor"], _async_wait_for_on ) await hass.async_block_till_done() async with asyncio.timeout(2): await on_future assert mock_device.entry.entry_id == entry_id - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is None await hass.async_block_till_done() reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is None assert await hass.config_entries.async_unload(entry.entry_id) @@ -262,7 +264,6 @@ async def test_entities_for_entire_platform_removed( object_id="mybinary_sensor_to_be_removed", key=1, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -276,7 +277,7 @@ async def test_entities_for_entire_platform_removed( entry = mock_device.entry entry_id = entry.entry_id storage_key = f"esphome.{entry_id}" - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.state == STATE_ON @@ -285,10 +286,10 @@ async def test_entities_for_entire_platform_removed( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None assert state.attributes[ATTR_RESTORED] is True @@ -298,10 +299,10 @@ async def test_entities_for_entire_platform_removed( entry=entry, ) assert mock_device.entry.entry_id == entry_id - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is None await hass.config_entries.async_unload(entry.entry_id) @@ -320,7 +321,6 @@ async def test_entity_info_object_ids( object_id="object_id_is_used", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -329,7 +329,7 @@ async def test_entity_info_object_ids( entity_info=entity_info, states=states, ) - state = hass.states.get("binary_sensor.test_object_id_is_used") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None @@ -345,13 +345,11 @@ async def test_deep_sleep_device( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), SensorInfo( object_id="my_sensor", key=3, name="my sensor", - unique_id="my_sensor", ), ] states = [ @@ -365,16 +363,16 @@ async def test_deep_sleep_device( states=states, device_info={"has_deep_sleep": True}, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(False) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNAVAILABLE state = hass.states.get("sensor.test_my_sensor") @@ -384,12 +382,12 @@ async def test_deep_sleep_device( await mock_device.mock_connect() await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(True) await hass.async_block_till_done() @@ -398,7 +396,7 @@ async def test_deep_sleep_device( mock_device.set_state(BinarySensorState(key=1, state=False, missing_state=False)) mock_device.set_state(SensorState(key=3, state=56, missing_state=False)) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_OFF state = hass.states.get("sensor.test_my_sensor") @@ -407,7 +405,7 @@ async def test_deep_sleep_device( await mock_device.mock_disconnect(True) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_OFF state = hass.states.get("sensor.test_my_sensor") @@ -418,7 +416,7 @@ async def test_deep_sleep_device( await hass.async_block_till_done() await mock_device.mock_disconnect(False) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNAVAILABLE state = hass.states.get("sensor.test_my_sensor") @@ -427,14 +425,14 @@ async def test_deep_sleep_device( await mock_device.mock_connect() await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() # Verify we do not dispatch any more state updates or # availability updates after the stop event is fired - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON @@ -451,7 +449,6 @@ async def test_esphome_device_without_friendly_name( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -464,7 +461,7 @@ async def test_esphome_device_without_friendly_name( states=states, device_info={"friendly_name": None}, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON @@ -481,7 +478,6 @@ async def test_entity_without_name_device_with_friendly_name( object_id="mybinary_sensor", key=1, name="", - unique_id="my_binary_sensor", ), ] states = [ @@ -514,7 +510,6 @@ async def test_entity_id_preserved_on_upgrade( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -555,7 +550,6 @@ async def test_entity_id_preserved_on_upgrade_old_format_entity_id( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -596,7 +590,6 @@ async def test_entity_id_preserved_on_upgrade_when_in_storage( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -655,7 +648,6 @@ async def test_deep_sleep_added_after_setup( object_id="test", key=1, name="test", - unique_id="test", ), ], states=[ @@ -688,6 +680,13 @@ async def test_deep_sleep_added_after_setup( **{**asdict(mock_device.device_info), "has_deep_sleep": True} ) mock_device.client.device_info = AsyncMock(return_value=new_device_info) + mock_device.client.device_info_and_list_entities = AsyncMock( + return_value=( + new_device_info, + mock_device.client.list_entities_services.return_value[0], + mock_device.client.list_entities_services.return_value[1], + ) + ) mock_device.device_info = new_device_info await mock_device.mock_connect() @@ -699,3 +698,992 @@ async def test_deep_sleep_added_after_setup( state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON + + +async def test_entity_assignment_to_sub_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test entities are assigned to correct sub devices.""" + device_registry = dr.async_get(hass) + + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=0), + SubDeviceInfo(device_id=22222222, name="Door Sensor", area_id=0), + ] + + device_info = { + "devices": sub_devices, + } + + # Create entities that belong to different devices + entity_info = [ + # Entity for main device (device_id=0) + BinarySensorInfo( + object_id="main_sensor", + key=1, + name="Main Sensor", + device_id=0, + ), + # Entity for sub device 1 + BinarySensorInfo( + object_id="motion", + key=2, + name="Motion", + device_id=11111111, + ), + # Entity for sub device 2 + BinarySensorInfo( + object_id="door", + key=3, + name="Door", + device_id=22222222, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False, device_id=0), + BinarySensorState(key=2, state=False, missing_state=False, device_id=11111111), + BinarySensorState(key=3, state=True, missing_state=False, device_id=22222222), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check main device + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + + # Check entities are assigned to correct devices + main_sensor = entity_registry.async_get("binary_sensor.test_main_sensor") + assert main_sensor is not None + assert main_sensor.device_id == main_device.id + + # Check sub device 1 entity + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + + motion_sensor = entity_registry.async_get("binary_sensor.motion_sensor_motion") + assert motion_sensor is not None + assert motion_sensor.device_id == sub_device_1.id + + # Check sub device 2 entity + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + + door_sensor = entity_registry.async_get("binary_sensor.door_sensor_door") + assert door_sensor is not None + assert door_sensor.device_id == sub_device_2.id + + # Check states + assert hass.states.get("binary_sensor.test_main_sensor").state == STATE_ON + assert hass.states.get("binary_sensor.motion_sensor_motion").state == STATE_OFF + assert hass.states.get("binary_sensor.door_sensor_door").state == STATE_ON + + # Check entity friendly names + # Main device entity should have: "{device_name} {entity_name}" + main_sensor_state = hass.states.get("binary_sensor.test_main_sensor") + assert main_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Test Main Sensor" + + # Sub device 1 entity should have: "Motion Sensor Motion" + motion_sensor_state = hass.states.get("binary_sensor.motion_sensor_motion") + assert motion_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Motion Sensor Motion" + + # Sub device 2 entity should have: "Door Sensor Door" + door_sensor_state = hass.states.get("binary_sensor.door_sensor_door") + assert door_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Door Sensor Door" + + +async def test_entity_friendly_names_with_empty_device_names( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test entity friendly names when sub-devices have empty names.""" + # Define sub devices with different name scenarios + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name + SubDeviceInfo( + device_id=22222222, name="Kitchen Light", area_id=0 + ), # Valid name + ] + + device_info = { + "devices": sub_devices, + "friendly_name": "Main Device", + } + + # Entity on sub-device with empty name + entity_info = [ + BinarySensorInfo( + object_id="motion", + key=1, + name="Motion Detected", + device_id=11111111, + ), + # Entity on sub-device with valid name + BinarySensorInfo( + object_id="status", + key=2, + name="Status", + device_id=22222222, + ), + # Entity with empty name on sub-device with valid name + BinarySensorInfo( + object_id="sensor", + key=3, + name="", # Empty entity name + device_id=22222222, + ), + # Entity on main device + BinarySensorInfo( + object_id="main_status", + key=4, + name="Main Status", + device_id=0, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=False, missing_state=False), + BinarySensorState(key=3, state=True, missing_state=False), + BinarySensorState(key=4, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check entity friendly name on sub-device with empty name + # Since sub device has empty name, it falls back to main device name "test" + state_1 = hass.states.get("binary_sensor.test_motion_detected") + assert state_1 is not None + # With has_entity_name, friendly name is "{device_name} {entity_name}" + # Since sub-device falls back to main device name: "Main Device Motion Detected" + assert state_1.attributes[ATTR_FRIENDLY_NAME] == "Main Device Motion Detected" + + # Check entity friendly name on sub-device with valid name + state_2 = hass.states.get("binary_sensor.kitchen_light_status") + assert state_2 is not None + # Device has name "Kitchen Light", entity has name "Status" + assert state_2.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light Status" + + # Test entity with empty name on sub-device + state_3 = hass.states.get("binary_sensor.kitchen_light") + assert state_3 is not None + # Entity has empty name, so friendly name is just the device name + assert state_3.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light" + + # Test entity on main device + state_4 = hass.states.get("binary_sensor.test_main_status") + assert state_4 is not None + assert state_4.attributes[ATTR_FRIENDLY_NAME] == "Main Device Main Status" + + +async def test_entity_switches_between_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entities can switch between devices correctly.""" + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device 1", area_id=0), + SubDeviceInfo(device_id=22222222, name="Sub Device 2", area_id=0), + ] + + device_info = { + "devices": sub_devices, + } + + # Create initial entity assigned to main device (no device_id) + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + # device_id omitted - entity belongs to main device + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False, device_id=0), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify entity is on main device + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == main_device.id + + # Test 1: Main device → Sub device 1 + updated_entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + device_id=11111111, # Now on sub device 1 + ), + ] + + # Update the entity info by changing what the mock returns + mock_client.list_entities_services = AsyncMock( + return_value=(updated_entity_info, []) + ) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, updated_entity_info, []) + ) + # Trigger a reconnect to simulate the entity info update + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Verify entity is now on sub device 1 + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == sub_device_1.id + + # Test 2: Sub device 1 → Sub device 2 + updated_entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + device_id=22222222, # Now on sub device 2 + ), + ] + + mock_client.list_entities_services = AsyncMock( + return_value=(updated_entity_info, []) + ) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, updated_entity_info, []) + ) + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Verify entity is now on sub device 2 + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == sub_device_2.id + + # Test 3: Sub device 2 → Main device + updated_entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + # device_id omitted - back to main device + ), + ] + + mock_client.list_entities_services = AsyncMock( + return_value=(updated_entity_info, []) + ) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, updated_entity_info, []) + ) + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Verify entity is back on main device + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == main_device.id + + +async def test_entity_id_uses_sub_device_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entity_id uses sub device name when entity belongs to sub device.""" + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="motion_sensor", area_id=0), + SubDeviceInfo(device_id=22222222, name="door_sensor", area_id=0), + ] + + device_info = { + "devices": sub_devices, + "name": "main_device", + } + + # Create entities that belong to different devices + entity_info = [ + # Entity for main device (device_id=0) + BinarySensorInfo( + object_id="main_sensor", + key=1, + name="Main Sensor", + device_id=0, + ), + # Entity for sub device 1 + BinarySensorInfo( + object_id="motion", + key=2, + name="Motion", + device_id=11111111, + ), + # Entity for sub device 2 + BinarySensorInfo( + object_id="door", + key=3, + name="Door", + device_id=22222222, + ), + # Entity without name on sub device + BinarySensorInfo( + object_id="sensor_no_name", + key=4, + name="", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=False, missing_state=False), + BinarySensorState(key=3, state=True, missing_state=False), + BinarySensorState(key=4, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check entity_id for main device entity + # Should be: binary_sensor.{main_device_name}_{object_id} + assert hass.states.get("binary_sensor.main_device_main_sensor") is not None + + # Check entity_id for sub device 1 entity + # Should be: binary_sensor.{sub_device_name}_{object_id} + assert hass.states.get("binary_sensor.motion_sensor_motion") is not None + + # Check entity_id for sub device 2 entity + # Should be: binary_sensor.{sub_device_name}_{object_id} + assert hass.states.get("binary_sensor.door_sensor_door") is not None + + # Check entity_id for entity without name on sub device + # Should be: binary_sensor.{sub_device_name} + assert hass.states.get("binary_sensor.motion_sensor") is not None + + +async def test_entity_id_with_empty_sub_device_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test entity_id when sub device has empty name (falls back to main device name).""" + # Define sub device with empty name + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name + ] + + device_info = { + "devices": sub_devices, + "name": "main_device", + } + + # Create entity on sub device with empty name + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Sensor", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # When sub device has empty name, entity_id should use main device name + # Should be: binary_sensor.{main_device_name}_{object_id} + assert hass.states.get("binary_sensor.main_device_sensor") is not None + + +async def test_unique_id_migration_when_entity_moves_between_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that unique_id is migrated when entity moves between devices while entity_id stays the same.""" + # Initial setup: entity on main device + device_info = { + "name": "test", + "devices": [], # No sub-devices initially + } + + # Entity on main device + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", # This field is not used by the integration + device_id=0, # Main device + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check initial entity + state = hass.states.get("binary_sensor.test_temperature") + assert state is not None + + # Get the entity from registry + entity_entry = entity_registry.async_get("binary_sensor.test_temperature") + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Initial unique_id should not have @device_id suffix since it's on main device + assert "@" not in initial_unique_id + + # Add sub-device to device info + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + ] + + # Get the config entry from hass + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Build device_id_to_name mapping like manager.py does + entry_data = entry.runtime_data + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name for sub_device in sub_devices + } + + # Create a new DeviceInfo with sub-devices since it's frozen + # Get the current device info and convert to dict + current_device_info = mock_client.device_info.return_value + device_info_dict = asdict(current_device_info) + + # Update the devices list + device_info_dict["devices"] = sub_devices + + # Create new DeviceInfo with updated devices + new_device_info = DeviceInfo(**device_info_dict) + + # Update mock_client to return new device info + mock_client.device_info.return_value = new_device_info + + # Update entity info - same key and object_id but now on sub-device + new_entity_info = [ + BinarySensorInfo( + object_id="temperature", # Same object_id + key=1, # Same key - this is what identifies the entity + name="Temperature", # This field is not used + device_id=22222222, # Now on sub-device + ), + ] + + # Update the entity info by changing what the mock returns + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, new_entity_info, []) + ) + + # Trigger a reconnect to simulate the entity info update + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Wait for entity to be updated + await hass.async_block_till_done() + + # The entity_id doesn't change when moving between devices + # Only the unique_id gets updated with @device_id suffix + state = hass.states.get("binary_sensor.test_temperature") + assert state is not None + + # Get updated entity from registry - entity_id should be the same + entity_entry = entity_registry.async_get("binary_sensor.test_temperature") + assert entity_entry is not None + + # Unique ID should have been migrated to include @device_id + # This is done by our build_device_unique_id wrapper + expected_unique_id = f"{initial_unique_id}@22222222" + assert entity_entry.unique_id == expected_unique_id + + # Entity should now be associated with the sub-device + sub_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device is not None + assert entity_entry.device_id == sub_device.id + + +async def test_unique_id_migration_sub_device_to_main_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that unique_id is migrated when entity moves from sub-device to main device.""" + # Initial setup: entity on sub-device + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Entity on sub-device + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + device_id=22222222, # On sub-device + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check initial entity + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get the entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Initial unique_id should have @device_id suffix since it's on sub-device + assert "@22222222" in initial_unique_id + + # Update entity info - move to main device + new_entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + device_id=0, # Now on main device + ), + ] + + # Update the entity info + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, new_entity_info, []) + ) + + # Trigger a reconnect + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + await hass.async_block_till_done() + + # The entity_id should remain the same + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get updated entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + + # Unique ID should have been migrated to remove @device_id suffix + expected_unique_id = initial_unique_id.replace("@22222222", "") + assert entity_entry.unique_id == expected_unique_id + + # Entity should now be associated with the main device + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + assert entity_entry.device_id == main_device.id + + +async def test_unique_id_migration_between_sub_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that unique_id is migrated when entity moves between sub-devices.""" + # Initial setup: two sub-devices + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + SubDeviceInfo(device_id=33333333, name="bedroom_controller", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Entity on first sub-device + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + device_id=22222222, # On kitchen_controller + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check initial entity + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get the entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Initial unique_id should have @22222222 suffix + assert "@22222222" in initial_unique_id + + # Update entity info - move to second sub-device + new_entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + device_id=33333333, # Now on bedroom_controller + ), + ] + + # Update the entity info + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, new_entity_info, []) + ) + + # Trigger a reconnect + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + await hass.async_block_till_done() + + # The entity_id should remain the same + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get updated entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + + # Unique ID should have been migrated from @22222222 to @33333333 + expected_unique_id = initial_unique_id.replace("@22222222", "@33333333") + assert entity_entry.unique_id == expected_unique_id + + # Entity should now be associated with the second sub-device + bedroom_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + assert bedroom_device is not None + assert entity_entry.device_id == bedroom_device.id + + +async def test_entity_device_id_rename_in_yaml( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entities are re-added as new when user renames device_id in YAML config.""" + # Initial setup: entity on sub-device with device_id 11111111 + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="old_device", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Entity on sub-device + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Sensor", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify initial entity setup + state = hass.states.get("binary_sensor.old_device_sensor") + assert state is not None + assert state.state == STATE_ON + + # Wait for entity to be registered + await hass.async_block_till_done() + + # Get the entity from registry + entity_entry = entity_registry.async_get("binary_sensor.old_device_sensor") + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Should have @11111111 suffix + assert "@11111111" in initial_unique_id + + # Simulate user renaming device_id in YAML config + # The device_id hash changes from 11111111 to 99999999 + # This is treated as a completely new device + renamed_sub_devices = [ + SubDeviceInfo(device_id=99999999, name="renamed_device", area_id=0), + ] + + # Get the config entry from hass + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Update device_id_to_name mapping + entry_data = entry.runtime_data + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name for sub_device in renamed_sub_devices + } + + # Create new DeviceInfo with renamed device + current_device_info = mock_client.device_info.return_value + device_info_dict = asdict(current_device_info) + device_info_dict["devices"] = renamed_sub_devices + new_device_info = DeviceInfo(**device_info_dict) + mock_client.device_info.return_value = new_device_info + + # Entity info now has the new device_id + new_entity_info = [ + BinarySensorInfo( + object_id="sensor", # Same object_id + key=1, # Same key + name="Sensor", + device_id=99999999, # New device_id after rename + ), + ] + + # Update the entity info + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(new_device_info, new_entity_info, []) + ) + + # Trigger a reconnect to simulate the YAML config change + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + await hass.async_block_till_done() + + # The old entity should be gone (device was deleted) + state = hass.states.get("binary_sensor.old_device_sensor") + assert state is None + + # A new entity should exist with a new entity_id based on the new device name + # This is a completely new entity, not a migrated one + state = hass.states.get("binary_sensor.renamed_device_sensor") + assert state is not None + assert state.state == STATE_ON + + # Get the new entity from registry + entity_entry = entity_registry.async_get("binary_sensor.renamed_device_sensor") + assert entity_entry is not None + + # Unique ID should have the new device_id + base_unique_id = initial_unique_id.replace("@11111111", "") + expected_unique_id = f"{base_unique_id}@99999999" + assert entity_entry.unique_id == expected_unique_id + + # Entity should be associated with the new device + renamed_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_99999999")} + ) + assert renamed_device is not None + assert entity_entry.device_id == renamed_device.id + + +@pytest.mark.parametrize( + ("unicode_name", "expected_entity_id"), + [ + ("Árvíztűrő tükörfúrógép", "binary_sensor.test_arvizturo_tukorfurogep"), + ("Teplota venku °C", "binary_sensor.test_teplota_venku_degc"), + ("Влажность %", "binary_sensor.test_vlazhnost"), + ("中文传感器", "binary_sensor.test_zhong_wen_chuan_gan_qi"), + ("Sensor à côté", "binary_sensor.test_sensor_a_cote"), + ("τιμή αισθητήρα", "binary_sensor.test_time_aisthetera"), + ], +) +async def test_entity_with_unicode_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + unicode_name: str, + expected_entity_id: str, +) -> None: + """Test that entities with Unicode names get proper entity IDs. + + This verifies the fix for Unicode entity names where ESPHome's C++ code + sanitizes Unicode characters to underscores (not UTF-8 aware), but the + entity_id should use the original name from entity_info.name rather than + the sanitized object_id to preserve Unicode characters properly. + """ + # Simulate what ESPHome would send - a heavily sanitized object_id + # but with the original Unicode name preserved + sanitized_object_id = "_".join("_" * len(word) for word in unicode_name.split()) + + entity_info = [ + BinarySensorInfo( + object_id=sanitized_object_id, # ESPHome sends the sanitized version + key=1, + name=unicode_name, # But also sends the original Unicode name, + ) + ] + states = [BinarySensorState(key=1, state=True)] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + # The entity_id should be based on the Unicode name, properly transliterated + state = hass.states.get(expected_entity_id) + assert state is not None, f"Entity with ID {expected_entity_id} should exist" + assert state.state == STATE_ON + + # The friendly name should preserve the original Unicode characters + assert state.attributes["friendly_name"] == f"Test {unicode_name}" + + # Verify that using the sanitized object_id would NOT find the entity + # This confirms we're not using the object_id for entity_id generation + wrong_entity_id = f"binary_sensor.test_{sanitized_object_id}" + wrong_state = hass.states.get(wrong_entity_id) + assert wrong_state is None, f"Entity should NOT be found at {wrong_entity_id}" + + +async def test_entity_without_name_uses_device_name_only( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that entities without a name fall back to using device name only. + + When entity_info.name is empty, the entity_id should just be domain.device_name + without the object_id appended, as noted in the comment in entity.py. + """ + entity_info = [ + BinarySensorInfo( + object_id="some_sanitized_id", + key=1, + name="", # Empty name, + ) + ] + states = [BinarySensorState(key=1, state=True)] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + # With empty name, entity_id should just be domain.device_name + expected_entity_id = "binary_sensor.test" + state = hass.states.get(expected_entity_id) + assert state is not None, f"Entity {expected_entity_id} should exist" + assert state.state == STATE_ON diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 886e5317462..044c3c7a8f1 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -15,49 +15,6 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockGenericDeviceEntryType -async def test_migrate_entity_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_client: APIClient, - mock_generic_device_entry: MockGenericDeviceEntryType, -) -> None: - """Test a generic sensor entity unique id migration.""" - entity_registry.async_get_or_create( - "sensor", - "esphome", - "my_sensor", - suggested_object_id="old_sensor", - disabled_by=None, - ) - entity_info = [ - SensorInfo( - object_id="mysensor", - key=1, - name="my sensor", - unique_id="my_sensor", - entity_category=ESPHomeEntityCategory.DIAGNOSTIC, - icon="mdi:leaf", - ) - ] - states = [SensorState(key=1, state=50)] - user_service = [] - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=entity_info, - user_service=user_service, - states=states, - ) - state = hass.states.get("sensor.old_sensor") - assert state is not None - assert state.state == "50" - entry = entity_registry.async_get("sensor.old_sensor") - assert entry is not None - assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None - # Note that ESPHome includes the EntityInfo type in the unique id - # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" - - async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -84,7 +41,6 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index d4688e8ab4e..3cff3184bf1 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -20,7 +20,6 @@ async def test_generic_event_entity( object_id="myevent", key=1, name="my event", - unique_id="my_event", event_types=["type1", "type2"], device_class=EventDeviceClass.BUTTON, ) @@ -36,7 +35,7 @@ async def test_generic_event_entity( await hass.async_block_till_done() # Test initial state - state = hass.states.get("event.test_myevent") + state = hass.states.get("event.test_my_event") assert state is not None assert state.state == "2024-04-24T00:00:00.000+00:00" assert state.attributes["event_type"] == "type1" @@ -44,7 +43,7 @@ async def test_generic_event_entity( # Test device becomes unavailable await device.mock_disconnect(True) await hass.async_block_till_done() - state = hass.states.get("event.test_myevent") + state = hass.states.get("event.test_my_event") assert state.state == STATE_UNAVAILABLE # Test device becomes available again @@ -52,6 +51,6 @@ async def test_generic_event_entity( await hass.async_block_till_done() # Event entity should be available immediately without waiting for data - state = hass.states.get("event.test_myevent") + state = hass.states.get("event.test_my_event") assert state.state == "2024-04-24T00:00:00.000+00:00" assert state.attributes["event_type"] == "type1" diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 05a95fe0e00..763e95d3e6f 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -44,7 +44,6 @@ async def test_fan_entity_with_all_features_old_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=True, supports_speed=True, supports_oscillation=True, @@ -66,71 +65,71 @@ async def test_fan_entity_with_all_features_old_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_myfan") + state = hass.states.get("fan.test_my_fan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 20}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.LOW, state=True)] + [call(key=1, speed=FanSpeed.LOW, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 50}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.MEDIUM, state=True)] + [call(key=1, speed=FanSpeed.MEDIUM, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_DECREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.LOW, state=True)] + [call(key=1, speed=FanSpeed.LOW, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_INCREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.HIGH, state=True)] + [call(key=1, speed=FanSpeed.HIGH, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 100}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.HIGH, state=True)] + [call(key=1, speed=FanSpeed.HIGH, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -147,7 +146,6 @@ async def test_fan_entity_with_all_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supported_speed_count=4, supports_direction=True, supports_speed=True, @@ -172,120 +170,136 @@ async def test_fan_entity_with_all_features_new_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_myfan") + state = hass.states.get("fan.test_my_fan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 20}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=1, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=1, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 50}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=2, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_DECREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=2, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_INCREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=4, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 100}, - blocking=True, - ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) - mock_client.fan_command.reset_mock() - - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 0}, - blocking=True, - ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) - mock_client.fan_command.reset_mock() - - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_OSCILLATING: True}, - blocking=True, - ) - mock_client.fan_command.assert_has_calls([call(key=1, oscillating=True)]) - mock_client.fan_command.reset_mock() - - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_OSCILLATING: False}, - blocking=True, - ) - mock_client.fan_command.assert_has_calls([call(key=1, oscillating=False)]) - mock_client.fan_command.reset_mock() - - await hass.services.async_call( - FAN_DOMAIN, - SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_DIRECTION: "forward"}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, direction=FanDirection.FORWARD)] + [call(key=1, speed_level=4, state=True, device_id=0)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 0}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: True}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, oscillating=True, device_id=0)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: False}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, oscillating=False, device_id=0)] ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_DIRECTION: "reverse"}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "forward"}, blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, direction=FanDirection.REVERSE)] + [call(key=1, direction=FanDirection.FORWARD, device_id=0)] + ) + mock_client.fan_command.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "reverse"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls( + [call(key=1, direction=FanDirection.REVERSE, device_id=0)] ) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PRESET_MODE: "Preset1"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.assert_has_calls( + [call(key=1, preset_mode="Preset1", device_id=0)] + ) mock_client.fan_command.reset_mock() @@ -301,7 +315,6 @@ async def test_fan_entity_with_no_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=False, supports_speed=False, supports_oscillation=False, @@ -316,24 +329,24 @@ async def test_fan_entity_with_no_features_new_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_myfan") + state = hass.states.get("fan.test_my_fan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=True)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=True, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 0cf3e10f11e..bf602a6fa84 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -56,7 +56,6 @@ async def test_light_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF], @@ -70,18 +69,18 @@ async def test_light_on_off( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF)] + [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF, device_id=0)] ) mock_client.light_command.reset_mock() @@ -98,7 +97,6 @@ async def test_light_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS], @@ -112,25 +110,32 @@ async def test_light_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, + ) + ] ) mock_client.light_command.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -140,6 +145,7 @@ async def test_light_brightness( state=True, color_mode=LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -148,29 +154,29 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_TRANSITION: 2}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_TRANSITION: 2}, blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=False, transition_length=2.0)] + [call(key=1, state=False, transition_length=2.0, device_id=0)] ) mock_client.light_command.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_FLASH: FLASH_LONG}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_FLASH: FLASH_LONG}, blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=False, flash_length=10.0)] + [call(key=1, state=False, flash_length=10.0, device_id=0)] ) mock_client.light_command.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_TRANSITION: 2}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_TRANSITION: 2}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -180,6 +186,7 @@ async def test_light_brightness( state=True, transition_length=2.0, color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -188,7 +195,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -198,6 +205,7 @@ async def test_light_brightness( state=True, flash_length=2.0, color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -216,7 +224,6 @@ async def test_light_legacy_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS, 2], @@ -234,7 +241,7 @@ async def test_light_legacy_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -244,11 +251,18 @@ async def test_light_legacy_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, + ) + ] ) mock_client.light_command.reset_mock() @@ -265,7 +279,6 @@ async def test_light_brightness_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF, ESPColorMode.BRIGHTNESS], @@ -283,7 +296,7 @@ async def test_light_brightness_on_off( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -294,7 +307,7 @@ async def test_light_brightness_on_off( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -303,6 +316,7 @@ async def test_light_brightness_on_off( key=1, state=True, color_mode=ESPColorMode.BRIGHTNESS.value, + device_id=0, ) ] ) @@ -311,7 +325,7 @@ async def test_light_brightness_on_off( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -321,6 +335,7 @@ async def test_light_brightness_on_off( state=True, color_mode=ESPColorMode.BRIGHTNESS.value, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -339,7 +354,6 @@ async def test_light_legacy_white_converted_to_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -357,14 +371,14 @@ async def test_light_legacy_white_converted_to_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -375,6 +389,7 @@ async def test_light_legacy_white_converted_to_brightness( color_mode=LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + device_id=0, ) ] ) @@ -403,7 +418,6 @@ async def test_light_legacy_white_with_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode, color_mode_2], @@ -417,7 +431,7 @@ async def test_light_legacy_white_with_rgb( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -428,7 +442,7 @@ async def test_light_legacy_white_with_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_WHITE: 60}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_WHITE: 60}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -439,6 +453,7 @@ async def test_light_legacy_white_with_rgb( brightness=pytest.approx(0.23529411764705882), white=1.0, color_mode=color_mode, + device_id=0, ) ] ) @@ -457,7 +472,6 @@ async def test_light_brightness_on_off_with_unknown_color_mode( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -480,14 +494,14 @@ async def test_light_brightness_on_off_with_unknown_color_mode( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -496,6 +510,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( key=1, state=True, color_mode=LIGHT_COLOR_CAPABILITY_UNKNOWN, + device_id=0, ) ] ) @@ -504,7 +519,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -514,6 +529,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( state=True, color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -532,7 +548,6 @@ async def test_light_on_and_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -549,18 +564,18 @@ async def test_light_on_and_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF)] + [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF, device_id=0)] ) mock_client.light_command.reset_mock() @@ -584,7 +599,6 @@ async def test_rgb_color_temp_light( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=color_modes, @@ -602,14 +616,14 @@ async def test_rgb_color_temp_light( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -618,6 +632,7 @@ async def test_rgb_color_temp_light( key=1, state=True, color_mode=ESPColorMode.BRIGHTNESS, + device_id=0, ) ] ) @@ -626,7 +641,7 @@ async def test_rgb_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -636,6 +651,7 @@ async def test_rgb_color_temp_light( state=True, color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -644,7 +660,7 @@ async def test_rgb_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -654,6 +670,7 @@ async def test_rgb_color_temp_light( state=True, color_mode=ESPColorMode.COLOR_TEMPERATURE, color_temperature=400, + device_id=0, ) ] ) @@ -672,7 +689,6 @@ async def test_light_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.ON_OFF @@ -688,14 +704,14 @@ async def test_light_rgb( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -706,6 +722,7 @@ async def test_light_rgb( color_mode=LightColorCapability.RGB | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -714,7 +731,7 @@ async def test_light_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -726,6 +743,7 @@ async def test_light_rgb( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -735,7 +753,7 @@ async def test_light_rgb( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -752,6 +770,7 @@ async def test_light_rgb( | LightColorCapability.BRIGHTNESS, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -760,7 +779,7 @@ async def test_light_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -773,6 +792,7 @@ async def test_light_rgb( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -791,7 +811,6 @@ async def test_light_rgbw( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.WHITE @@ -822,7 +841,7 @@ async def test_light_rgbw( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] @@ -831,7 +850,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -843,6 +862,7 @@ async def test_light_rgbw( | LightColorCapability.WHITE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -851,7 +871,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -864,6 +884,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -873,7 +894,7 @@ async def test_light_rgbw( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -892,6 +913,7 @@ async def test_light_rgbw( white=0, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -900,7 +922,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -915,6 +937,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, 0, 0), + device_id=0, ) ] ) @@ -923,7 +946,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -938,6 +961,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -956,7 +980,6 @@ async def test_light_rgbww_with_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -992,7 +1015,7 @@ async def test_light_rgbww_with_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -1008,7 +1031,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1017,6 +1040,7 @@ async def test_light_rgbww_with_cold_warm_white_support( key=1, state=True, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, + device_id=0, ) ] ) @@ -1025,7 +1049,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1035,6 +1059,7 @@ async def test_light_rgbww_with_cold_warm_white_support( state=True, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1044,7 +1069,7 @@ async def test_light_rgbww_with_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -1059,6 +1084,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_mode=ESPColorMode.RGB, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1067,7 +1093,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1078,6 +1104,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_brightness=1.0, color_mode=ESPColorMode.RGB, rgb=(1.0, 1.0, 1.0), + device_id=0, ) ] ) @@ -1086,7 +1113,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1098,6 +1125,7 @@ async def test_light_rgbww_with_cold_warm_white_support( white=1, color_mode=ESPColorMode.RGB_WHITE, rgb=(1.0, 1.0, 1.0), + device_id=0, ) ] ) @@ -1107,7 +1135,7 @@ async def test_light_rgbww_with_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBWW_COLOR: (255, 255, 255, 255, 255), }, blocking=True, @@ -1122,6 +1150,7 @@ async def test_light_rgbww_with_cold_warm_white_support( warm_white=1, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1130,7 +1159,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1140,6 +1169,7 @@ async def test_light_rgbww_with_cold_warm_white_support( state=True, color_temperature=400.0, color_mode=ESPColorMode.COLOR_TEMPERATURE, + device_id=0, ) ] ) @@ -1158,7 +1188,6 @@ async def test_light_rgbww_without_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -1194,7 +1223,7 @@ async def test_light_rgbww_without_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBWW] @@ -1204,7 +1233,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1217,6 +1246,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1225,7 +1255,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1239,6 +1269,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1248,7 +1279,7 @@ async def test_light_rgbww_without_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -1268,6 +1299,7 @@ async def test_light_rgbww_without_cold_warm_white_support( white=0, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1277,7 +1309,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1294,6 +1326,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, pytest.approx(0.5462962962962963), 1.0), + device_id=0, ) ] ) @@ -1302,7 +1335,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1319,6 +1352,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, pytest.approx(0.5462962962962963), 1.0), + device_id=0, ) ] ) @@ -1328,7 +1362,7 @@ async def test_light_rgbww_without_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBWW_COLOR: (255, 255, 255, 255, 255), }, blocking=True, @@ -1347,6 +1381,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1355,7 +1390,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1372,6 +1407,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, 0, 0), + device_id=0, ) ] ) @@ -1390,7 +1426,6 @@ async def test_light_color_temp( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1416,7 +1451,7 @@ async def test_light_color_temp( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1426,7 +1461,7 @@ async def test_light_color_temp( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1437,6 +1472,7 @@ async def test_light_color_temp( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1445,10 +1481,10 @@ async def test_light_color_temp( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1464,7 +1500,6 @@ async def test_light_color_temp_no_mireds_set( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=0, max_mireds=0, supported_color_modes=[ @@ -1490,7 +1525,7 @@ async def test_light_color_temp_no_mireds_set( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1500,7 +1535,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1511,6 +1546,7 @@ async def test_light_color_temp_no_mireds_set( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1519,7 +1555,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 6000}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 6000}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1531,6 +1567,7 @@ async def test_light_color_temp_no_mireds_set( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1539,10 +1576,10 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1558,7 +1595,6 @@ async def test_light_color_temp_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1591,7 +1627,7 @@ async def test_light_color_temp_legacy( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1604,7 +1640,7 @@ async def test_light_color_temp_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1615,6 +1651,7 @@ async def test_light_color_temp_legacy( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1623,10 +1660,10 @@ async def test_light_color_temp_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1642,7 +1679,6 @@ async def test_light_rgb_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1677,7 +1713,7 @@ async def test_light_rgb_legacy( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1687,7 +1723,7 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1695,6 +1731,7 @@ async def test_light_rgb_legacy( call( key=1, state=True, + device_id=0, ) ] ) @@ -1703,16 +1740,16 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1722,6 +1759,7 @@ async def test_light_rgb_legacy( state=True, rgb=(1.0, 1.0, 1.0), color_brightness=1.0, + device_id=0, ) ] ) @@ -1740,7 +1778,6 @@ async def test_light_effects( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, effects=["effect1", "effect2"], @@ -1762,7 +1799,7 @@ async def test_light_effects( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_EFFECT_LIST] == ["effect1", "effect2"] @@ -1770,7 +1807,7 @@ async def test_light_effects( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_EFFECT: "effect1"}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_EFFECT: "effect1"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1780,6 +1817,7 @@ async def test_light_effects( state=True, color_mode=ESPColorMode.BRIGHTNESS, effect="effect1", + device_id=0, ) ] ) @@ -1803,7 +1841,6 @@ async def test_only_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_modes], @@ -1830,7 +1867,7 @@ async def test_only_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] @@ -1839,18 +1876,18 @@ async def test_only_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=color_modes)] + [call(key=1, state=True, color_mode=color_modes, device_id=0)] ) mock_client.light_command.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1860,6 +1897,7 @@ async def test_only_cold_warm_white_support( state=True, color_mode=color_modes, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1868,7 +1906,7 @@ async def test_only_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1878,6 +1916,7 @@ async def test_only_cold_warm_white_support( state=True, color_mode=color_modes, color_temperature=400.0, + device_id=0, ) ] ) @@ -1897,7 +1936,6 @@ async def test_light_no_color_modes( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode], @@ -1911,7 +1949,7 @@ async def test_light_no_color_modes( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] @@ -1919,8 +1957,10 @@ async def test_light_no_color_modes( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=True, color_mode=0)]) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=0, device_id=0)] + ) mock_client.light_command.reset_mock() diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index 96c91b1d79f..93e9c0704c3 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -34,7 +34,6 @@ async def test_lock_entity_no_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=False, requires_code=False, ) @@ -47,17 +46,17 @@ async def test_lock_entity_no_open( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_mylock") + state = hass.states.get("lock.test_my_lock") assert state is not None assert state.state == LockState.UNLOCKING await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK, device_id=0)]) mock_client.lock_command.reset_mock() @@ -72,7 +71,6 @@ async def test_lock_entity_start_locked( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", ) ] states = [LockEntityState(key=1, state=ESPHomeLockState.LOCKED)] @@ -83,7 +81,7 @@ async def test_lock_entity_start_locked( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_mylock") + state = hass.states.get("lock.test_my_lock") assert state is not None assert state.state == LockState.LOCKED @@ -99,7 +97,6 @@ async def test_lock_entity_supports_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=True, requires_code=True, ) @@ -112,32 +109,34 @@ async def test_lock_entity_supports_open( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_mylock") + state = hass.states.get("lock.test_my_lock") assert state is not None assert state.state == LockState.LOCKING await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK, device_id=0)]) mock_client.lock_command.reset_mock() await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.UNLOCK, None)]) + mock_client.lock_command.assert_has_calls( + [call(1, LockCommand.UNLOCK, None, device_id=0)] + ) mock_client.lock_command.reset_mock() await hass.services.async_call( LOCK_DOMAIN, SERVICE_OPEN, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN, device_id=0)]) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index dfadf6ad6d7..86dfb6e9ea3 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,12 +1,15 @@ """Test ESPHome manager.""" import asyncio +import base64 import logging -from unittest.mock import AsyncMock, Mock, call +from typing import Any +from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( APIClient, APIConnectionError, + AreaInfo, DeviceInfo, EncryptionPlaintextAPIError, HomeassistantServiceCall, @@ -14,6 +17,7 @@ from aioesphomeapi import ( InvalidEncryptionKeyAPIError, LogLevel, RequiresEncryptionAPIError, + SubDeviceInfo, UserService, UserServiceArg, UserServiceArgType, @@ -25,11 +29,15 @@ from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, + CONF_NOISE_PSK, CONF_SUBSCRIBE_LOGS, DOMAIN, STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) +from homeassistant.components.esphome.encryption_key_storage import ( + ENCRYPTION_KEY_STORAGE_KEY, +) from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT from homeassistant.components.tag import DOMAIN as TAG_DOMAIN from homeassistant.const import ( @@ -42,6 +50,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( + area_registry as ar, device_registry as dr, entity_registry as er, issue_registry as ir, @@ -407,14 +416,17 @@ async def test_unique_id_updated_to_mac( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455aa", - ) + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) + device_info = DeviceInfo(mac_address="1122334455aa") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -438,15 +450,20 @@ async def test_add_missing_bluetooth_mac_address( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455aa", - bluetooth_mac_address="AA:BB:CC:DD:EE:FF", - ) + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) + device_info = DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -480,8 +497,11 @@ async def test_unique_id_not_updated_if_name_same_and_already_mac( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="test") + device_info = DeviceInfo(mac_address="1122334455ab", name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -510,8 +530,11 @@ async def test_unique_id_updated_if_name_unset_and_already_mac( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="test") + device_info = DeviceInfo(mac_address="1122334455ab", name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -545,8 +568,11 @@ async def test_unique_id_not_updated_if_name_different_and_already_mac( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="different") + device_info = DeviceInfo(mac_address="1122334455ab", name="different") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -578,12 +604,17 @@ async def test_name_updated_only_if_mac_matches( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="new") + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) + device_info = DeviceInfo(mac_address="1122334455aa", name="new") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -613,12 +644,17 @@ async def test_name_updated_only_if_mac_was_unset( entry.add_to_hass(hass) subscribe_done = hass.loop.create_future() - def async_subscribe_states(*args, **kwargs) -> None: + def async_subscribe_home_assistant_states_and_services(*args, **kwargs) -> None: subscribe_done.set_result(None) - mock_client.subscribe_states = async_subscribe_states - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="new") + mock_client.subscribe_home_assistant_states_and_services = ( + async_subscribe_home_assistant_states_and_services + ) + device_info = DeviceInfo(mac_address="1122334455aa", name="new") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -655,8 +691,11 @@ async def test_connection_aborted_wrong_device( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="different") + device_info = DeviceInfo(mac_address="1122334455ab", name="different") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -686,10 +725,12 @@ async def test_connection_aborted_wrong_device( hostname="test", macaddress="1122334455aa", ) - new_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="test") - ) + device_info = DeviceInfo(mac_address="1122334455aa", name="test") + new_info = AsyncMock(return_value=device_info) mock_client.device_info = new_info + # Also need to update device_info_and_list_entities + new_combined_info = AsyncMock(return_value=(device_info, [], [])) + mock_client.device_info_and_list_entities = new_combined_info result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info ) @@ -703,7 +744,8 @@ async def test_connection_aborted_wrong_device( } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 2 + # Check that either device_info or device_info_and_list_entities was called + assert len(new_info.mock_calls) + len(new_combined_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @@ -732,8 +774,11 @@ async def test_connection_aborted_wrong_device_same_name( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455ab", name="test") + device_info = DeviceInfo(mac_address="1122334455ab", name="test") + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(entry.entry_id) @@ -760,10 +805,12 @@ async def test_connection_aborted_wrong_device_same_name( hostname="test", macaddress="1122334455aa", ) - new_info = AsyncMock( - return_value=DeviceInfo(mac_address="1122334455aa", name="test") - ) + device_info = DeviceInfo(mac_address="1122334455aa", name="test") + new_info = AsyncMock(return_value=device_info) mock_client.device_info = new_info + # Also need to update device_info_and_list_entities + new_combined_info = AsyncMock(return_value=(device_info, [], [])) + mock_client.device_info_and_list_entities = new_combined_info result = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info ) @@ -777,7 +824,8 @@ async def test_connection_aborted_wrong_device_same_name( } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 2 + # Check that either device_info or device_info_and_list_entities was called + assert len(new_info.mock_calls) + len(new_combined_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @@ -806,6 +854,12 @@ async def test_failure_during_connect( mock_client.disconnect = async_disconnect mock_client.device_info = AsyncMock(side_effect=APIConnectionError("fail")) + mock_client.list_entities_services = AsyncMock( + side_effect=APIConnectionError("fail") + ) + mock_client.device_info_and_list_entities = AsyncMock( + side_effect=APIConnectionError("fail") + ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -972,6 +1026,9 @@ async def test_esphome_device_with_dash_in_name_user_services( # Verify the service can be removed mock_client.list_entities_services = AsyncMock(return_value=([], [service1])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], [service1]) + ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() @@ -1028,6 +1085,9 @@ async def test_esphome_user_services_ignores_invalid_arg_types( # Verify the service can be removed mock_client.list_entities_services = AsyncMock(return_value=([], [service2])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], [service2]) + ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() @@ -1136,6 +1196,9 @@ async def test_esphome_user_services_changes( # Verify the service can be updated mock_client.list_entities_services = AsyncMock(return_value=([], [new_service1])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], [new_service1]) + ) await device.mock_disconnect(True) await hass.async_block_till_done() await device.mock_connect() @@ -1162,6 +1225,7 @@ async def test_esphome_user_services_changes( async def test_esphome_device_with_suggested_area( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, @@ -1176,7 +1240,31 @@ async def test_esphome_device_with_suggested_area( dev = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) - assert dev.suggested_area == "kitchen" + assert dev.area_id == area_registry.async_get_area_by_name("kitchen").id + + +async def test_esphome_device_area_priority( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that device_info.area takes priority over suggested_area.""" + device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "suggested_area": "kitchen", + "area": AreaInfo(area_id=0, name="Living Room"), + }, + ) + await hass.async_block_till_done() + entry = device.entry + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + # Should use device_info.area.name instead of suggested_area + assert dev.area_id == area_registry.async_get_area_by_name("Living Room").id async def test_esphome_device_with_project( @@ -1444,6 +1532,10 @@ async def test_device_adds_friendly_name( **{**device.device_info.to_dict(), "friendly_name": "I have a friendly name"} ) mock_client.device_info = AsyncMock(return_value=device.device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], []) + ) await device.mock_connect() await hass.async_block_till_done() dev = dev_reg.async_get_device( @@ -1500,3 +1592,758 @@ async def test_assist_in_progress_issue_deleted( ) is None ) + + +async def test_sub_device_creation( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices are created in device registry.""" + device_registry = dr.async_get(hass) + + # Define areas + areas = [ + AreaInfo(area_id=1, name="Living Room"), + AreaInfo(area_id=2, name="Bedroom"), + AreaInfo(area_id=3, name="Kitchen"), + ] + + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=1), + SubDeviceInfo(device_id=22222222, name="Light Switch", area_id=1), + SubDeviceInfo(device_id=33333333, name="Temperature Sensor", area_id=2), + ] + + device_info = { + "areas": areas, + "devices": sub_devices, + "area": AreaInfo(area_id=0, name="Main Hub"), + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + + # Check main device is created + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + assert main_device.area_id == area_registry.async_get_area_by_name("Main Hub").id + + # Check sub devices are created + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + assert sub_device_1.name == "Motion Sensor" + assert ( + sub_device_1.area_id == area_registry.async_get_area_by_name("Living Room").id + ) + assert sub_device_1.via_device_id == main_device.id + + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + assert sub_device_2.name == "Light Switch" + assert ( + sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id + ) + assert sub_device_2.via_device_id == main_device.id + + sub_device_3 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + assert sub_device_3 is not None + assert sub_device_3.name == "Temperature Sensor" + assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id + assert sub_device_3.via_device_id == main_device.id + + +async def test_sub_device_cleanup( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices are removed when they no longer exist.""" + device_registry = dr.async_get(hass) + + # Initial sub devices + sub_devices_initial = [ + SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0), + SubDeviceInfo(device_id=22222222, name="Device 2", area_id=0), + SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0), + ] + + device_info = { + "devices": sub_devices_initial, + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + + # Verify all sub devices exist + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + is not None + ) + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + is not None + ) + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + is not None + ) + + # Now update with fewer sub devices (device 2 removed) + sub_devices_updated = [ + SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0), + SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0), + ] + + # Update device info + device.device_info = DeviceInfo( + name="test", + friendly_name="Test", + esphome_version="1.0.0", + mac_address="11:22:33:44:55:AA", + devices=sub_devices_updated, + ) + + # Update the mock client to return the new device info + mock_client.device_info = AsyncMock(return_value=device.device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device.device_info, [], []) + ) + + # Simulate reconnection which triggers device registry update + await device.mock_connect() + await hass.async_block_till_done() + + # Verify device 2 was removed + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + is not None + ) + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + is None + ) # Should be removed + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + is not None + ) + + +async def test_sub_device_with_empty_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices with empty names are handled correctly.""" + device_registry = dr.async_get(hass) + + # Define sub devices with empty names + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name + SubDeviceInfo(device_id=22222222, name="Valid Name", area_id=0), + ] + + device_info = { + "devices": sub_devices, + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + await hass.async_block_till_done() + + # Check sub device with empty name + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + # Empty sub-device names should fall back to main device name + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert sub_device_1.name == main_device.name + + # Check sub device with valid name + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + assert sub_device_2.name == "Valid Name" + + +async def test_sub_device_references_main_device_area( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices can reference the main device's area.""" + device_registry = dr.async_get(hass) + + # Define areas - note we don't include area_id=0 in the areas list + areas = [ + AreaInfo(area_id=1, name="Living Room"), + AreaInfo(area_id=2, name="Bedroom"), + ] + + # Define sub devices - one references the main device's area (area_id=0) + sub_devices = [ + SubDeviceInfo( + device_id=11111111, name="Motion Sensor", area_id=0 + ), # Main device area + SubDeviceInfo( + device_id=22222222, name="Light Switch", area_id=1 + ), # Living Room + SubDeviceInfo( + device_id=33333333, name="Temperature Sensor", area_id=2 + ), # Bedroom + ] + + device_info = { + "areas": areas, + "devices": sub_devices, + "area": AreaInfo(area_id=0, name="Main Hub Area"), + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + + # Check main device has correct area + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + assert ( + main_device.area_id == area_registry.async_get_area_by_name("Main Hub Area").id + ) + + # Check sub device 1 uses main device's area + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + assert ( + sub_device_1.area_id == area_registry.async_get_area_by_name("Main Hub Area").id + ) + + # Check sub device 2 uses Living Room + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + assert ( + sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id + ) + + # Check sub device 3 uses Bedroom + sub_device_3 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + assert sub_device_3 is not None + assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_generation( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test that a device without a key in storage gets a new one generated.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify the key was generated and set + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once() + + # Verify config entry was updated + assert entry.data[CONF_NOISE_PSK] == expected_key + + +async def test_manager_retrieves_key_from_storage_on_reconnect( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test that manager retrieves encryption key from storage during reconnect.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + # Set up storage with existing key + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + # Create entry without noise PSK (will be loaded from storage) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key retrieval from storage + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify noise_encryption_set_key was called with the stored key + mock_client.noise_encryption_set_key.assert_called_once_with(test_key.encode()) + + # Verify config entry was updated with key from storage + assert entry.data[CONF_NOISE_PSK] == test_key + + +async def test_manager_handle_dynamic_encryption_key_guard_clauses( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test _handle_dynamic_encryption_key guard clauses and early returns.""" + # Test guard clause - no unique_id + entry_no_id = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=None, # No unique ID - should not generate key + ) + entry_no_id.add_to_hass(hass) + + # Set up device without unique ID + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry_no_id, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": "11:22:33:44:55:aa", + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # noise_encryption_set_key should not be called when no unique_id + mock_client.noise_encryption_set_key = AsyncMock() + await device.mock_disconnect(True) + await device.mock_connect() + + mock_client.noise_encryption_set_key.assert_not_called() + + +async def test_manager_handle_dynamic_encryption_key_edge_cases( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test _handle_dynamic_encryption_key edge cases for better coverage.""" + mac_address = "11:22:33:44:55:aa" + + # Test device without encryption support + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Set up device without encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": False, # No encryption support + }, + ) + + # noise_encryption_set_key should not be called when encryption not supported + mock_client.noise_encryption_set_key = AsyncMock() + await device.mock_disconnect(True) + await device.mock_connect() + + mock_client.noise_encryption_set_key.assert_not_called() + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_dynamic_encryption_key_generation_flow( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test the complete dynamic encryption key generation flow.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify the complete flow + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once() + assert entry.data[CONF_NOISE_PSK] == expected_key + + # Verify key was stored in hass_storage + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_handle_dynamic_encryption_key_no_existing_key( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test _handle_dynamic_encryption_key when no existing key is found.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify key generation flow + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once() + + # Verify config entry was updated + assert entry.data[CONF_NOISE_PSK] == expected_key + + # Verify key was stored + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_handle_dynamic_encryption_key_device_set_key_fails( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test _handle_dynamic_encryption_key when noise_encryption_set_key returns False.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods - set_key returns False + mock_client.noise_encryption_set_key = AsyncMock(return_value=False) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Reset mocks since initial connection already happened + mock_token_bytes.reset_mock() + mock_client.noise_encryption_set_key.reset_mock() + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify key generation was attempted with the expected key + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once_with( + base64.b64encode(test_key_bytes) + ) + + # Verify config entry was NOT updated since set_key failed + assert CONF_NOISE_PSK not in entry.data + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_handle_dynamic_encryption_key_connection_error( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test _handle_dynamic_encryption_key when noise_encryption_set_key raises APIConnectionError.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods - set_key raises APIConnectionError + mock_client.noise_encryption_set_key = AsyncMock( + side_effect=APIConnectionError("Connection failed") + ) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify key generation was attempted twice (once during setup, once during reconnect) + # This is expected because the first attempt failed with connection error + assert mock_token_bytes.call_count == 2 + mock_token_bytes.assert_called_with(32) + assert mock_client.noise_encryption_set_key.call_count == 2 + + # Verify config entry was NOT updated since connection error occurred + assert CONF_NOISE_PSK not in entry.data + + # Verify key was NOT stored due to connection error + assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index ccc3ed3e70a..b5805298b97 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -27,8 +27,11 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, + STATE_PLAYING, BrowseMedia, MediaClass, MediaType, @@ -55,8 +58,9 @@ async def test_media_player_entity( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY,TURN_OFF,TURN_ON + feature_flags=1201037, ) ] states = [ @@ -71,7 +75,7 @@ async def test_media_player_entity( user_service=user_service, states=states, ) - state = hass.states.get("media_player.test_mymedia_player") + state = hass.states.get("media_player.test_my_media_player") assert state is not None assert state.state == "paused" @@ -79,13 +83,13 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_VOLUME_MUTED: True, }, blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.MUTE)] + [call(1, command=MediaPlayerCommand.MUTE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -93,13 +97,13 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_VOLUME_MUTED: True, }, blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.MUTE)] + [call(1, command=MediaPlayerCommand.MUTE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -107,24 +111,26 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_VOLUME_LEVEL: 0.5, }, blocking=True, ) - mock_client.media_player_command.assert_has_calls([call(1, volume=0.5)]) + mock_client.media_player_command.assert_has_calls( + [call(1, volume=0.5, device_id=0)] + ) mock_client.media_player_command.reset_mock() await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", }, blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.PAUSE)] + [call(1, command=MediaPlayerCommand.PAUSE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -132,12 +138,12 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", }, blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.PLAY)] + [call(1, command=MediaPlayerCommand.PLAY, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -145,15 +151,122 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", }, blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.STOP)] + [call(1, command=MediaPlayerCommand.STOP, device_id=0)] ) mock_client.media_player_command.reset_mock() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.TURN_OFF, device_id=0)] + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.TURN_ON, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + + +async def test_media_player_entity_with_undefined_flags( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that media_player handles undefined feature flags gracefully.""" + # Include existing flags (PAUSE=1, PLAY=16384, VOLUME_SET=4) + # plus undefined bits (bit 6=64, bit 23=8388608) + # Total: 1 + 16384 + 4 + 64 + 8388608 = 8405061 + entity_info = [ + MediaPlayerInfo( + object_id="mymedia_player_undefined", + key=1, + name="my media_player undefined", + supports_pause=True, + # PAUSE,PLAY,VOLUME_SET + undefined bits 6 and 23 + feature_flags=8405061, + ) + ] + states = [ + MediaPlayerEntityState( + key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + # Verify entity is created successfully despite undefined flags + state = hass.states.get("media_player.test_my_media_player_undefined") + assert state is not None + assert state.state == STATE_PLAYING + + # Verify supported features only include known flags + # Should have PAUSE, PLAY, and VOLUME_SET + supported_features = state.attributes.get("supported_features", 0) + # PAUSE=1, VOLUME_SET=4, PLAY=16384 = 16389 + assert supported_features == 16389 + + # Verify entity works correctly with known features + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PLAY, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PAUSE, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + ATTR_MEDIA_VOLUME_LEVEL: 0.7, + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, volume=0.7, device_id=0)] + ) + async def test_media_player_entity_with_source( hass: HomeAssistant, @@ -200,8 +313,9 @@ async def test_media_player_entity_with_source( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, ) ] states = [ @@ -216,7 +330,7 @@ async def test_media_player_entity_with_source( user_service=user_service, states=states, ) - state = hass.states.get("media_player.test_mymedia_player") + state = hass.states.get("media_player.test_my_media_player") assert state is not None assert state.state == "playing" @@ -225,7 +339,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: "media-source://local/xz", }, @@ -249,7 +363,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: "audio/mp3", ATTR_MEDIA_CONTENT_ID: "media-source://local/xy", }, @@ -257,7 +371,14 @@ async def test_media_player_entity_with_source( ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="http://www.example.com/xy.mp3", announcement=None)] + [ + call( + 1, + media_url="http://www.example.com/xy.mp3", + announcement=None, + device_id=0, + ) + ] ) client = await hass_ws_client() @@ -265,7 +386,7 @@ async def test_media_player_entity_with_source( { "id": 1, "type": "media_player/browse_media", - "entity_id": "media_player.test_mymedia_player", + "entity_id": "media_player.test_my_media_player", } ) response = await client.receive_json() @@ -275,7 +396,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, ATTR_MEDIA_CONTENT_ID: "media-source://tts?message=hello", ATTR_MEDIA_ANNOUNCE: True, @@ -284,7 +405,14 @@ async def test_media_player_entity_with_source( ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="media-source://tts?message=hello", announcement=True)] + [ + call( + 1, + media_url="media-source://tts?message=hello", + announcement=True, + device_id=0, + ) + ] ) @@ -302,8 +430,9 @@ async def test_media_player_proxy( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, supported_formats=[ MediaPlayerSupportedFormat( format="flac", @@ -339,7 +468,7 @@ async def test_media_player_proxy( connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} ) assert dev is not None - state = hass.states.get("media_player.test_mymedia_player") + state = hass.states.get("media_player.test_my_media_player") assert state is not None assert state.state == "paused" @@ -356,7 +485,7 @@ async def test_media_player_proxy( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, }, @@ -387,7 +516,7 @@ async def test_media_player_proxy( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, ATTR_MEDIA_ANNOUNCE: True, @@ -417,7 +546,7 @@ async def test_media_player_proxy( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, ATTR_MEDIA_EXTRA: { @@ -461,8 +590,9 @@ async def test_media_player_formats_reload_preserves_data( object_id="test_media_player", key=1, name="Test Media Player", - unique_id="test_unique_id", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, supported_formats=supported_formats, ) ], @@ -475,7 +605,7 @@ async def test_media_player_formats_reload_preserves_data( await hass.async_block_till_done() # Verify entity was created - state = hass.states.get("media_player.test_test_media_player") + state = hass.states.get("media_player.test_Test_Media_Player") assert state is not None assert state.state == "idle" @@ -486,7 +616,7 @@ async def test_media_player_formats_reload_preserves_data( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_ENTITY_ID: "media_player.test_Test_Media_Player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, }, @@ -507,7 +637,7 @@ async def test_media_player_formats_reload_preserves_data( await hass.async_block_till_done() # Verify entity still exists after reload - state = hass.states.get("media_player.test_test_media_player") + state = hass.states.get("media_player.test_Test_Media_Player") assert state is not None # Test that play_media still works after reload with announcement @@ -515,7 +645,7 @@ async def test_media_player_formats_reload_preserves_data( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_ENTITY_ID: "media_player.test_Test_Media_Player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, ATTR_MEDIA_ANNOUNCE: True, diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index 9a711f2766e..02b58649fec 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -35,7 +35,6 @@ async def test_generic_number_entity( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -50,17 +49,17 @@ async def test_generic_number_entity( user_service=user_service, states=states, ) - state = hass.states.get("number.test_mynumber") + state = hass.states.get("number.test_my_number") assert state is not None assert state.state == "50" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_mynumber", ATTR_VALUE: 50}, + {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 50}, blocking=True, ) - mock_client.number_command.assert_has_calls([call(1, 50)]) + mock_client.number_command.assert_has_calls([call(1, 50, device_id=0)]) mock_client.number_command.reset_mock() @@ -75,7 +74,6 @@ async def test_generic_number_nan( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -91,7 +89,7 @@ async def test_generic_number_nan( user_service=user_service, states=states, ) - state = hass.states.get("number.test_mynumber") + state = hass.states.get("number.test_my_number") assert state is not None assert state.state == STATE_UNKNOWN @@ -107,7 +105,6 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -123,7 +120,7 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( user_service=user_service, states=states, ) - state = hass.states.get("number.test_mynumber") + state = hass.states.get("number.test_my_number") assert state is not None assert state.state == "42" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes @@ -140,7 +137,6 @@ async def test_generic_number_entity_set_when_disconnected( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -162,7 +158,7 @@ async def test_generic_number_entity_set_when_disconnected( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_mynumber", ATTR_VALUE: 20}, + {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 20}, blocking=True, ) mock_client.number_command.reset_mock() diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index 692a7dd9cc9..f64cb806950 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -55,10 +55,13 @@ async def test_device_conflict_manual( disconnect_done.set_result(None) mock_client.disconnect = async_disconnect - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455ab", name="test", model="esp32-iso-poe" - ) + device_info = DeviceInfo( + mac_address="1122334455ab", name="test", model="esp32-iso-poe" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -102,10 +105,13 @@ async def test_device_conflict_manual( assert data["type"] == FlowResultType.FORM assert data["step_id"] == "manual" - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="11:22:33:44:55:aa", name="test", model="esp32-iso-poe" - ) + device_info = DeviceInfo( + mac_address="11:22:33:44:55:aa", name="test", model="esp32-iso-poe" + ) + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(device_info, [], []) ) caplog.clear() data = await process_repair_fix_flow(client, flow_id) @@ -133,7 +139,6 @@ async def test_device_conflict_migration( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] @@ -145,12 +150,12 @@ async def test_device_conflict_migration( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON mock_config_entry = device.entry - ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry assert ent_reg_entry.unique_id == "11:22:33:44:55:AA-binary_sensor-mybinary_sensor" entries = er.async_entries_for_config_entry( @@ -170,6 +175,11 @@ async def test_device_conflict_migration( mac_address="11:22:33:44:55:AB", name="test", model="esp32-iso-poe" ) mock_client.device_info = AsyncMock(return_value=new_device_info) + # Keep the same entity_info when reloading + mock_client.list_entities_services = AsyncMock(return_value=(entity_info, [])) + mock_client.device_info_and_list_entities = AsyncMock( + return_value=(new_device_info, entity_info, []) + ) device.device_info = new_device_info await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -222,7 +232,7 @@ async def test_device_conflict_migration( assert issue_registry.async_get_issue(DOMAIN, issue_id) is None assert mock_config_entry.unique_id == "11:22:33:44:55:ab" - ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry assert ent_reg_entry.unique_id == "11:22:33:44:55:AB-binary_sensor-mybinary_sensor" diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 1dc37ca3cad..14673f5ffb9 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -67,7 +67,6 @@ async def test_select_generic_entity( object_id="myselect", key=1, name="my select", - unique_id="my_select", options=["a", "b"], ) ] @@ -79,17 +78,17 @@ async def test_select_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("select.test_myselect") + state = hass.states.get("select.test_my_select") assert state is not None assert state.state == "a" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: "select.test_myselect", ATTR_OPTION: "b"}, + {ATTR_ENTITY_ID: "select.test_my_select", ATTR_OPTION: "b"}, blocking=True, ) - mock_client.select_command.assert_has_calls([call(1, "b")]) + mock_client.select_command.assert_has_calls([call(1, "b", device_id=0)]) async def test_wake_word_select_no_wake_words( diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 6763d2ab9a9..6d3d59b9b4a 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -13,18 +13,28 @@ from aioesphomeapi import ( TextSensorInfo, TextSensorState, ) +import pytest from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, + async_rounded_state, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -44,7 +54,6 @@ async def test_generic_numeric_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=50)] @@ -55,35 +64,35 @@ async def test_generic_numeric_sensor( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" # Test updating state mock_device.set_state(SensorState(key=1, state=60)) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "60" # Test sending the same state again mock_device.set_state(SensorState(key=1, state=60)) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "60" # Test we can still update after the same state mock_device.set_state(SensorState(key=1, state=70)) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "70" # Test invalid data from the underlying api does not crash us mock_device.set_state(SensorState(key=1, state=object())) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "70" @@ -100,7 +109,6 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) @@ -113,11 +121,11 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_ICON] == "mdi:leaf" - entry = entity_registry.async_get("sensor.test_mysensor") + entry = entity_registry.async_get("sensor.test_my_sensor") assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) @@ -137,7 +145,6 @@ async def test_generic_numeric_sensor_state_class_measurement( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", state_class=ESPHomeSensorStateClass.MEASUREMENT, device_class="power", unit_of_measurement="W", @@ -151,11 +158,11 @@ async def test_generic_numeric_sensor_state_class_measurement( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - entry = entity_registry.async_get("sensor.test_mysensor") + entry = entity_registry.async_get("sensor.test_my_sensor") assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) @@ -174,7 +181,6 @@ async def test_generic_numeric_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class="timestamp", ) ] @@ -186,7 +192,7 @@ async def test_generic_numeric_sensor_device_class_timestamp( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "2023-06-22T18:43:52+00:00" @@ -202,7 +208,6 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", legacy_last_reset_type=LastResetType.AUTO, state_class=ESPHomeSensorStateClass.MEASUREMENT, ) @@ -215,7 +220,7 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING @@ -232,7 +237,6 @@ async def test_generic_numeric_sensor_no_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [] @@ -243,7 +247,7 @@ async def test_generic_numeric_sensor_no_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -259,7 +263,6 @@ async def test_generic_numeric_sensor_nan_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=math.nan, missing_state=False)] @@ -270,7 +273,7 @@ async def test_generic_numeric_sensor_nan_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -286,7 +289,6 @@ async def test_generic_numeric_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=True, missing_state=True)] @@ -297,7 +299,7 @@ async def test_generic_numeric_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -313,7 +315,6 @@ async def test_generic_text_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state="i am a teapot")] @@ -324,7 +325,7 @@ async def test_generic_text_sensor( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "i am a teapot" @@ -340,7 +341,6 @@ async def test_generic_text_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state=True, missing_state=True)] @@ -351,7 +351,7 @@ async def test_generic_text_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -367,7 +367,6 @@ async def test_generic_text_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.TIMESTAMP, ) ] @@ -379,7 +378,7 @@ async def test_generic_text_sensor_device_class_timestamp( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "2023-06-22T18:43:52+00:00" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP @@ -396,7 +395,6 @@ async def test_generic_text_sensor_device_class_date( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.DATE, ) ] @@ -408,7 +406,7 @@ async def test_generic_text_sensor_device_class_date( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "2023-06-22" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DATE @@ -425,7 +423,6 @@ async def test_generic_numeric_sensor_empty_string_uom( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", unit_of_measurement="", ) ] @@ -437,7 +434,66 @@ async def test_generic_numeric_sensor_empty_string_uom( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "123" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement", "state_value", "expected_precision"), + [ + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 23.456, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 0.1, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, -25.789, 1), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1234.56, 0), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1.23456, 3), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 0.123, 3), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 1234.5, 0), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 12.3456, 2), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 230.45, 1), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 3.3, 1), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 15.678, 2), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 0.015, 3), + (SensorDeviceClass.ATMOSPHERIC_PRESSURE, UnitOfPressure.HPA, 1013.25, 1), + (SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, 1.01325, 3), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 45.67, 1), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 4567.0, 0), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 87.654, 1), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 45.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 95.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 100.0, 1), + ], +) +async def test_suggested_display_precision_by_device_class( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + device_class: SensorDeviceClass, + unit_of_measurement: str, + state_value: float, + expected_precision: int, +) -> None: + """Test suggested display precision for different device classes.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + accuracy_decimals=expected_precision, + device_class=device_class.value, + unit_of_measurement=unit_of_measurement, + ) + ] + states = [SensorState(key=1, state=state_value)] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert float( + async_rounded_state(hass, "sensor.test_my_sensor", state) + ) == pytest.approx(round(state_value, expected_precision)) diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index b3c13ee2fe5..2d054a7317d 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -2,17 +2,17 @@ from unittest.mock import call -from aioesphomeapi import APIClient, SwitchInfo, SwitchState +from aioesphomeapi import APIClient, SubDeviceInfo, SwitchInfo, SwitchState from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from .conftest import MockGenericDeviceEntryType +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType async def test_switch_generic_entity( @@ -26,7 +26,6 @@ async def test_switch_generic_entity( object_id="myswitch", key=1, name="my switch", - unique_id="my_switch", ) ] states = [SwitchState(key=1, state=True)] @@ -37,22 +36,111 @@ async def test_switch_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("switch.test_myswitch") + state = hass.states.get("switch.test_my_switch") assert state is not None assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_myswitch"}, + {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) - mock_client.switch_command.assert_has_calls([call(1, True)]) + mock_client.switch_command.assert_has_calls([call(1, True, device_id=0)]) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_myswitch"}, + {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) - mock_client.switch_command.assert_has_calls([call(1, False)]) + mock_client.switch_command.assert_has_calls([call(1, False, device_id=0)]) + + +async def test_switch_sub_device_non_zero_device_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test switch on sub-device with non-zero device_id passes through to API.""" + # Create sub-device + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device", area_id=0), + ] + device_info = { + "name": "test", + "devices": sub_devices, + } + # Create switches on both main device and sub-device + entity_info = [ + SwitchInfo( + object_id="main_switch", + key=1, + name="Main Switch", + device_id=0, # Main device + ), + SwitchInfo( + object_id="sub_switch", + key=2, + name="Sub Switch", + device_id=11111111, # Sub-device + ), + ] + # States for both switches + states = [ + SwitchState(key=1, state=True, device_id=0), + SwitchState(key=2, state=False, device_id=11111111), + ] + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify both entities exist with correct states + main_state = hass.states.get("switch.test_main_switch") + assert main_state is not None + assert main_state.state == STATE_ON + + sub_state = hass.states.get("switch.sub_device_sub_switch") + assert sub_state is not None + assert sub_state.state == STATE_OFF + + # Test turning on the sub-device switch - should pass device_id=11111111 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.sub_device_sub_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(2, True, device_id=11111111)]) + mock_client.switch_command.reset_mock() + + # Test turning off the sub-device switch - should pass device_id=11111111 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.sub_device_sub_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(2, False, device_id=11111111)]) + mock_client.switch_command.reset_mock() + + # Test main device switch still uses device_id=0 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_main_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, True, device_id=0)]) + mock_client.switch_command.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_main_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, False, device_id=0)]) diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index 899b4a732ca..b1e84544e3e 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -26,7 +26,6 @@ async def test_generic_text_entity( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -41,17 +40,17 @@ async def test_generic_text_entity( user_service=user_service, states=states, ) - state = hass.states.get("text.test_mytext") + state = hass.states.get("text.test_my_text") assert state is not None assert state.state == "hello world" await hass.services.async_call( TEXT_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "text.test_mytext", ATTR_VALUE: "goodbye"}, + {ATTR_ENTITY_ID: "text.test_my_text", ATTR_VALUE: "goodbye"}, blocking=True, ) - mock_client.text_command.assert_has_calls([call(1, "goodbye")]) + mock_client.text_command.assert_has_calls([call(1, "goodbye", device_id=0)]) mock_client.text_command.reset_mock() @@ -66,7 +65,6 @@ async def test_generic_text_entity_no_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -81,7 +79,7 @@ async def test_generic_text_entity_no_state( user_service=user_service, states=states, ) - state = hass.states.get("text.test_mytext") + state = hass.states.get("text.test_my_text") assert state is not None assert state.state == STATE_UNKNOWN @@ -97,7 +95,6 @@ async def test_generic_text_entity_missing_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -112,6 +109,6 @@ async def test_generic_text_entity_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("text.test_mytext") + state = hass.states.get("text.test_my_text") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index 543a903f0a9..176510d4e65 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -26,7 +26,6 @@ async def test_generic_time_entity( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, hour=12, minute=34, second=56)] @@ -37,17 +36,17 @@ async def test_generic_time_entity( user_service=user_service, states=states, ) - state = hass.states.get("time.test_mytime") + state = hass.states.get("time.test_my_time") assert state is not None assert state.state == "12:34:56" await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "time.test_mytime", ATTR_TIME: "01:23:45"}, + {ATTR_ENTITY_ID: "time.test_my_time", ATTR_TIME: "01:23:45"}, blocking=True, ) - mock_client.time_command.assert_has_calls([call(1, 1, 23, 45)]) + mock_client.time_command.assert_has_calls([call(1, 1, 23, 45, device_id=0)]) mock_client.time_command.reset_mock() @@ -62,7 +61,6 @@ async def test_generic_time_missing_state( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, missing_state=True)] @@ -73,6 +71,6 @@ async def test_generic_time_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("time.test_mytime") + state = hass.states.get("time.test_my_time") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 960cc016efc..859189f5ed9 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -35,7 +35,7 @@ from tests.typing import WebSocketGenerator RELEASE_SUMMARY = "This is a release summary" RELEASE_URL = "https://esphome.io/changelog" -ENTITY_ID = "update.test_myupdate" +ENTITY_ID = "update.test_my_update" @pytest.fixture(autouse=True) @@ -436,7 +436,6 @@ async def test_generic_device_update_entity( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -470,7 +469,6 @@ async def test_generic_device_update_entity_has_update( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -544,7 +542,9 @@ async def test_generic_device_update_entity_has_update( assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - mock_client.update_command.assert_called_with(key=1, command=UpdateCommand.CHECK) + mock_client.update_command.assert_called_with( + key=1, command=UpdateCommand.CHECK, device_id=0 + ) async def test_update_entity_release_notes( @@ -559,7 +559,6 @@ async def test_update_entity_release_notes( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index bc5c77a62d6..4f57a27708c 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -36,7 +36,6 @@ async def test_valve_entity( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=True, supports_stop=True, ) @@ -55,7 +54,7 @@ async def test_valve_entity( user_service=user_service, states=states, ) - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.OPENING assert state.attributes[ATTR_CURRENT_POSITION] == 50 @@ -63,44 +62,44 @@ async def test_valve_entity( await hass.services.async_call( VALVE_DOMAIN, SERVICE_CLOSE_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( VALVE_DOMAIN, SERVICE_OPEN_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( VALVE_DOMAIN, SERVICE_SET_VALVE_POSITION, - {ATTR_ENTITY_ID: "valve.test_myvalve", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: "valve.test_my_valve", ATTR_POSITION: 50}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.5, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( VALVE_DOMAIN, SERVICE_STOP_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.valve_command.assert_has_calls([call(key=1, stop=True, device_id=0)]) mock_client.valve_command.reset_mock() mock_device.set_state( ESPHomeValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.CLOSED @@ -110,7 +109,7 @@ async def test_valve_entity( ) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.CLOSING @@ -118,7 +117,7 @@ async def test_valve_entity( ESPHomeValveState(key=1, position=1.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.OPEN @@ -134,7 +133,6 @@ async def test_valve_entity_without_position( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=False, supports_stop=False, ) @@ -153,7 +151,7 @@ async def test_valve_entity_without_position( user_service=user_service, states=states, ) - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.OPENING assert ATTR_CURRENT_POSITION not in state.attributes @@ -161,25 +159,25 @@ async def test_valve_entity_without_position( await hass.services.async_call( VALVE_DOMAIN, SERVICE_CLOSE_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( VALVE_DOMAIN, SERVICE_OPEN_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.valve_command.reset_mock() mock_device.set_state( ESPHomeValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.CLOSED diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 20d70902e83..ff34134b3fb 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -129,6 +129,7 @@ async def test_async_step_reauth( CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -639,6 +640,7 @@ async def test_reauth_errors( CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index bef44c92f34..800412fc9d4 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -125,7 +125,12 @@ async def test_get_trigger_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( @@ -155,7 +160,12 @@ async def test_get_trigger_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( diff --git a/tests/components/fan/test_intent.py b/tests/components/fan/test_intent.py new file mode 100644 index 00000000000..450d81e9dff --- /dev/null +++ b/tests/components/fan/test_intent.py @@ -0,0 +1,37 @@ +"""Intent tests for the fan platform.""" + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN, + SERVICE_TURN_ON, + intent as fan_intent, +) +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from tests.common import async_mock_service + + +async def test_set_speed_intent(hass: HomeAssistant) -> None: + """Test set speed intent for fans.""" + await fan_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_fan" + hass.states.async_set(entity_id, STATE_OFF) + calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + + response = await intent.async_handle( + hass, + "test", + fan_intent.INTENT_FAN_SET_SPEED, + {"name": {"value": "test fan"}, ATTR_PERCENTAGE: {"value": 50}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data == {"entity_id": entity_id, "percentage": 50} diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index 77937e3af54..d4568747d01 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -54,7 +54,7 @@ async def test_sensors( state = hass.states.get("sensor.flipr_myfliprid_chlorine") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mg/L" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "0.23654886" diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr index edba0ebe162..6a242c4d2ce 100644 --- a/tests/components/flo/snapshots/test_init.ambr +++ b/tests/components/flo/snapshots/test_init.ambr @@ -22,7 +22,6 @@ '98765', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Flo by Moen', @@ -32,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '6.1.1', 'via_device_id': None, }), @@ -57,7 +55,6 @@ '32839', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Flo by Moen', @@ -67,7 +64,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111112', - 'suggested_area': None, 'sw_version': '1.1.15', 'via_device_id': None, }), diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index f8b4093574f..43616693303 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -60,6 +60,21 @@ def setup_mock_foscam_camera(mock_foscam_camera): mock_foscam_camera.get_dev_info.return_value = (dev_info_rc, dev_info_data) mock_foscam_camera.get_port_info.return_value = (dev_info_rc, {}) mock_foscam_camera.is_asleep.return_value = (0, True) + mock_foscam_camera.get_infra_led_config.return_value = (0, {"mode": "1"}) + mock_foscam_camera.get_mirror_and_flip_setting.return_value = ( + 0, + {"isFlip": "0", "isMirror": "0"}, + ) + mock_foscam_camera.is_asleep.return_value = (0, "0") + mock_foscam_camera.getWhiteLightBrightness.return_value = (0, {"enable": "1"}) + mock_foscam_camera.getSirenConfig.return_value = (0, {"sirenEnable": "1"}) + mock_foscam_camera.getAudioVolume.return_value = (0, {"volume": "100"}) + mock_foscam_camera.getSpeakVolume.return_value = (0, {"SpeakVolume": "100"}) + mock_foscam_camera.getVoiceEnableState.return_value = (0, {"isEnable": "1"}) + mock_foscam_camera.getLedEnableState.return_value = (0, {"isEnable": "0"}) + mock_foscam_camera.getWdrMode.return_value = (0, {"mode": "0"}) + mock_foscam_camera.getHdrMode.return_value = (0, {"mode": "0"}) + mock_foscam_camera.get_motion_detect_config.return_value = (0, 1) return mock_foscam_camera diff --git a/tests/components/foscam/snapshots/test_switch.ambr b/tests/components/foscam/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f48df6b65e6 --- /dev/null +++ b/tests/components/foscam/snapshots/test_switch.ambr @@ -0,0 +1,385 @@ +# serializer version: 1 +# name: test_entities[switch.mock_title_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_flip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip_switch', + 'unique_id': '123ABC_is_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Flip', + }), + 'context': , + 'entity_id': 'switch.mock_title_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_infrared_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_infrared_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Infrared mode', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ir_switch', + 'unique_id': '123ABC_is_open_ir', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_infrared_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Infrared mode', + }), + 'context': , + 'entity_id': 'switch.mock_title_infrared_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[switch.mock_title_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_off_light_switch', + 'unique_id': '123ABC_is_turn_off_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Light', + }), + 'context': , + 'entity_id': 'switch.mock_title_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_mirror-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_mirror', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mirror', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mirror_switch', + 'unique_id': '123ABC_is_mirror', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_mirror-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Mirror', + }), + 'context': , + 'entity_id': 'switch.mock_title_mirror', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_siren_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_siren_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren alarm', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_alarm_switch', + 'unique_id': '123ABC_is_siren_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_siren_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Siren alarm', + }), + 'context': , + 'entity_id': 'switch.mock_title_siren_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[switch.mock_title_sleep_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_sleep_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep mode', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sleep_switch', + 'unique_id': '123ABC_sleep_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_sleep_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sleep mode', + }), + 'context': , + 'entity_id': 'switch.mock_title_sleep_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_volume_muted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_volume_muted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume muted', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_off_volume_switch', + 'unique_id': '123ABC_is_turn_off_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_volume_muted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Volume muted', + }), + 'context': , + 'entity_id': 'switch.mock_title_volume_muted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_white_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_white_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'White light', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_light_switch', + 'unique_id': '123ABC_is_open_white_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_white_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title White light', + }), + 'context': , + 'entity_id': 'switch.mock_title_white_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/foscam/test_init.py b/tests/components/foscam/test_init.py index a7b6a8c8f0b..7c7b1b8aee8 100644 --- a/tests/components/foscam/test_init.py +++ b/tests/components/foscam/test_init.py @@ -96,7 +96,7 @@ async def test_unique_id_migration_not_needed( assert entity_before.unique_id == f"{ENTRY_ID}_sleep_switch" with ( - # Mock a valid camera instance" + # Mock a valid camera instance patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, patch( "homeassistant.components.foscam.async_migrate_entry", diff --git a/tests/components/foscam/test_switch.py b/tests/components/foscam/test_switch.py new file mode 100644 index 00000000000..bd9eb380fbd --- /dev/null +++ b/tests/components/foscam/test_switch.py @@ -0,0 +1,35 @@ +"""Test for the switch platform entity of the foscam component.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.foscam.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_mock_foscam_camera +from .const import ENTRY_ID, VALID_CONFIG + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that coordinator returns the data we expect after the first refresh.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with ( + # Mock a valid camera instance" + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + patch("homeassistant.components.foscam.PLATFORMS", [Platform.SWITCH]), + ): + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 1b10ddb8fc1..4b352ccb8da 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -57,7 +57,7 @@ async def test_sensor_update_fail( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) await hass.async_block_till_done(wait_background_tasks=True) - assert "Error while uptaing the data: Boom" in caplog.text + assert "Error while updating the data: Boom" in caplog.text sensors = hass.states.async_all(SENSOR_DOMAIN) for sensor in sensors: diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index 61de0c99940..794d6ac4397 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -15,7 +15,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import FritzDeviceCoverMock, FritzDeviceSwitchMock, FritzEntityBaseMock +from . import ( + FritzDeviceCoverMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, +) from .const import MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed @@ -140,3 +145,42 @@ async def test_coordinator_automatic_registry_cleanup( assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1 + + +async def test_coordinator_workaround_sub_units_without_main_device( + hass: HomeAssistant, + fritz: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the workaround for sub units without main device.""" + fritz().get_devices.return_value = [ + FritzDeviceSensorMock( + ain="bad_device-1", + device_and_unit_id=("bad_device", "1"), + name="bad_sensor_sub", + ), + FritzDeviceSensorMock( + ain="good_device", + device_and_unit_id=("good_device", None), + name="good_sensor", + ), + FritzDeviceSensorMock( + ain="good_device-1", + device_and_unit_id=("good_device", "1"), + name="good_sensor_sub", + ), + ] + + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert len(device_entries) == 2 + assert device_entries[0].identifiers == {(DOMAIN, "good_device")} + assert device_entries[1].identifiers == {(DOMAIN, "bad_device")} diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f28742cdd0a..a6c35513dc3 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -26,6 +26,7 @@ from homeassistant.components.frontend import ( ) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -408,6 +409,35 @@ async def test_themes_reload_themes( assert msg["result"]["default_theme"] == "default" +@pytest.mark.usefixtures("frontend") +async def test_themes_reload_invalid( + hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket +) -> None: + """Test frontend.reload_themes service with an invalid theme.""" + + with patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "pink"}}}}, + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + with ( + patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"sad": "blue"}}}, + ), + pytest.raises(HomeAssistantError, match="Failed to reload themes"), + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await themes_ws_client.receive_json() + + assert msg["result"]["themes"] == {"happy": {"primary-color": "pink"}} + assert msg["result"]["default_theme"] == "default" + + async def test_missing_themes(ws_client: MockHAClientWebSocket) -> None: """Test that themes API works when themes are not defined.""" await ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index 5227755d852..289927a587b 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -142,6 +142,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Gummibaum Light', + 'max_acceptable': 675.0, + 'max_good': 450.0, + 'min_acceptable': 18.0, + 'min_good': 20.0, 'state_class': , 'unit_of_measurement': 'μmol/s⋅m²', }), @@ -261,6 +265,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', 'friendly_name': 'Gummibaum Moisture', + 'max_acceptable': 80.0, + 'max_good': 70.0, + 'min_acceptable': 25.0, + 'min_good': 35.0, 'state_class': , 'unit_of_measurement': '%', }), @@ -612,6 +620,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'conductivity', 'friendly_name': 'Gummibaum Salinity', + 'max_acceptable': 1.2, + 'max_good': 1.0, + 'min_acceptable': 0.4, + 'min_good': 0.6, 'state_class': , 'unit_of_measurement': , }), @@ -782,6 +794,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Gummibaum Temperature', + 'max_acceptable': 42.0, + 'max_good': 36.0, + 'min_acceptable': 10.0, + 'min_good': 17.0, 'state_class': , 'unit_of_measurement': , }), @@ -1002,6 +1018,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Kakaobaum Light', + 'max_acceptable': 675.0, + 'max_good': 450.0, + 'min_acceptable': 18.0, + 'min_good': 20.0, 'state_class': , 'unit_of_measurement': 'μmol/s⋅m²', }), @@ -1121,6 +1141,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', 'friendly_name': 'Kakaobaum Moisture', + 'max_acceptable': 80.0, + 'max_good': 70.0, + 'min_acceptable': 25.0, + 'min_good': 35.0, 'state_class': , 'unit_of_measurement': '%', }), @@ -1472,6 +1496,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'conductivity', 'friendly_name': 'Kakaobaum Salinity', + 'max_acceptable': 1.2, + 'max_good': 1.0, + 'min_acceptable': 0.4, + 'min_good': 0.6, 'state_class': , 'unit_of_measurement': , }), @@ -1642,6 +1670,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Kakaobaum Temperature', + 'max_acceptable': 42.0, + 'max_good': 36.0, + 'min_acceptable': 10.0, + 'min_good': 17.0, 'state_class': , 'unit_of_measurement': , }), diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index d363e0e69f3..0f877fce7db 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -29,8 +29,18 @@ def mock_entry(): ) -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.gardena_bluetooth.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.gardena_bluetooth.async_setup_entry", diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index 8dc9d220e85..e11d42d970e 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -6,6 +6,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'bluetooth', + '00000000-0000-0000-0000-000000000001', + ), }), 'disabled_by': None, 'entry_type': None, @@ -17,7 +21,6 @@ '00000000-0000-0000-0000-000000000001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -27,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.2.3', 'via_device_id': None, }) diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index c89ead450d2..4bc1e7e8dcb 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -2,6 +2,7 @@ # name: test_bluetooth_error_unavailable StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -20,6 +21,7 @@ # name: test_bluetooth_error_unavailable.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -38,6 +40,7 @@ # name: test_bluetooth_error_unavailable.2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -56,6 +59,7 @@ # name: test_bluetooth_error_unavailable.3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -110,6 +114,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -128,6 +133,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -146,6 +152,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -164,6 +171,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -182,6 +190,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw2-number.mock_title_open_for] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Open for', 'max': 1440, 'min': 0.0, @@ -200,6 +209,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -218,6 +228,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, diff --git a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr index c030332e75b..4a0da40a143 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr @@ -2,6 +2,7 @@ # name: test_setup StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), @@ -16,6 +17,7 @@ # name: test_setup.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), diff --git a/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr b/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr index 3527596c9b9..859c0eeb1fe 100644 --- a/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr +++ b/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr @@ -15,7 +15,6 @@ # --- # name: test_options[create_entry] FlowResultSnapshot({ - 'result': True, 'type': , }) # --- diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 254d4da5806..64db21eab8c 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -9,8 +9,8 @@ import pytest from homeassistant.components import generic_hygrostat from homeassistant.components.generic_hygrostat import DOMAIN from homeassistant.components.generic_hygrostat.config_flow import ConfigFlowHandler -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -119,10 +119,20 @@ def generic_hygrostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -201,7 +211,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -216,9 +226,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -229,8 +237,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -239,7 +251,83 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_hygrostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_hygrostat config entry is removed when the source entity is removed.""" @@ -263,9 +351,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -284,6 +370,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -305,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -315,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -333,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -352,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +479,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +489,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +511,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries @@ -427,13 +530,18 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_hygrostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -452,10 +560,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "humidifier"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "humidifier"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -466,7 +574,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -483,9 +590,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -505,11 +610,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_hygrostat config entry is updated with the new entity ID assert generic_hygrostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -518,3 +621,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + + generic_hygrostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": switch_entity_entry.entity_id, + "name": "My generic hygrostat", + "target_sensor": sensor_entity_entry.entity_id, + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=1, + minor_version=1, + ) + generic_hygrostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_hygrostat_config_entry.version == 1 + assert generic_hygrostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": "switch.test", + "name": "My generic hygrostat", + "target_sensor": "sensor.test", + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/generic_thermostat/snapshots/test_config_flow.ambr b/tests/components/generic_thermostat/snapshots/test_config_flow.ambr index ed757d1c2ae..e69e51e19cd 100644 --- a/tests/components/generic_thermostat/snapshots/test_config_flow.ambr +++ b/tests/components/generic_thermostat/snapshots/test_config_flow.ambr @@ -39,7 +39,6 @@ # --- # name: test_options[create_entry] FlowResultSnapshot({ - 'result': True, 'type': , }) # --- diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 9131e3ffdd4..ceca7ecc444 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -9,8 +9,8 @@ import pytest from homeassistant.components import generic_thermostat from homeassistant.components.generic_thermostat.config_flow import ConfigFlowHandler from homeassistant.components.generic_thermostat.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -117,10 +117,20 @@ def generic_thermostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +209,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -214,9 +224,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -227,8 +235,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -237,7 +249,84 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_thermostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_thermostat config entry is removed when the source entity is removed.""" @@ -261,9 +350,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -282,6 +369,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -304,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -314,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -332,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -351,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +480,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +490,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +512,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( generic_thermostat_config_entry.entry_id not in source_device_2.config_entries @@ -429,13 +533,20 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_thermostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( - generic_thermostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + generic_thermostat_config_entry.entry_id not in source_device_2.config_entries + ) # Check that the generic_thermostat config entry is not removed assert ( @@ -455,10 +566,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "heater"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "heater"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -469,7 +580,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -486,9 +596,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -508,11 +616,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_thermostat config entry is updated with the new entity ID assert generic_thermostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed assert ( @@ -522,3 +628,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_thermostat config entry from device.""" + + generic_thermostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": switch_entity_entry.entity_id, + "target_sensor": sensor_entity_entry.entity_id, + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=1, + minor_version=1, + ) + generic_thermostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_thermostat_config_entry.version == 1 + assert generic_thermostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": "switch.test", + "target_sensor": "sensor.test", + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 49388428805..a4dc0a39be6 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -1,16 +1,29 @@ """Tests for GIOS.""" -import json from unittest.mock import patch from homeassistant.components.gios.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) STATIONS = [ - {"id": 123, "stationName": "Test Name 1", "gegrLat": "99.99", "gegrLon": "88.88"}, - {"id": 321, "stationName": "Test Name 2", "gegrLat": "77.77", "gegrLon": "66.66"}, + { + "Identyfikator stacji": 123, + "Nazwa stacji": "Test Name 1", + "WGS84 φ N": "99.99", + "WGS84 λ E": "88.88", + }, + { + "Identyfikator stacji": 321, + "Nazwa stacji": "Test Name 2", + "WGS84 φ N": "77.77", + "WGS84 λ E": "66.66", + }, ] @@ -26,13 +39,13 @@ async def init_integration( entry_id="86129426118ae32020417a53712d6eef", ) - indexes = json.loads(await async_load_fixture(hass, "indexes.json", DOMAIN)) - station = json.loads(await async_load_fixture(hass, "station.json", DOMAIN)) - sensors = json.loads(await async_load_fixture(hass, "sensors.json", DOMAIN)) + indexes = await async_load_json_object_fixture(hass, "indexes.json", DOMAIN) + station = await async_load_json_array_fixture(hass, "station.json", DOMAIN) + sensors = await async_load_json_object_fixture(hass, "sensors.json", DOMAIN) if incomplete_data: - indexes["stIndexLevel"]["indexLevelName"] = "foo" - sensors["pm10"]["values"][0]["value"] = None - sensors["pm10"]["values"][1]["value"] = None + indexes["AqIndex"] = "foo" + sensors["pm10"]["Lista danych pomiarowych"][0]["Wartość"] = None + sensors["pm10"]["Lista danych pomiarowych"][1]["Wartość"] = None if invalid_indexes: indexes = {} diff --git a/tests/components/gios/fixtures/indexes.json b/tests/components/gios/fixtures/indexes.json index c53d1c78f6e..1fb46e9a4d8 100644 --- a/tests/components/gios/fixtures/indexes.json +++ b/tests/components/gios/fixtures/indexes.json @@ -1,29 +1,38 @@ { - "id": 123, - "stCalcDate": "2020-07-31 15:10:17", - "stIndexLevel": { "id": 1, "indexLevelName": "Dobry" }, - "stSourceDataDate": "2020-07-31 14:00:00", - "so2CalcDate": "2020-07-31 15:10:17", - "so2IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, - "so2SourceDataDate": "2020-07-31 14:00:00", - "no2CalcDate": 1596201017000, - "no2IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "no2SourceDataDate": "2020-07-31 14:00:00", - "coCalcDate": "2020-07-31 15:10:17", - "coIndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "coSourceDataDate": "2020-07-31 14:00:00", - "pm10CalcDate": "2020-07-31 15:10:17", - "pm10IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "pm10SourceDataDate": "2020-07-31 14:00:00", - "pm25CalcDate": "2020-07-31 15:10:17", - "pm25IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "pm25SourceDataDate": "2020-07-31 14:00:00", - "o3CalcDate": "2020-07-31 15:10:17", - "o3IndexLevel": { "id": 1, "indexLevelName": "Dobry" }, - "o3SourceDataDate": "2020-07-31 14:00:00", - "c6h6CalcDate": "2020-07-31 15:10:17", - "c6h6IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, - "c6h6SourceDataDate": "2020-07-31 14:00:00", - "stIndexStatus": true, - "stIndexCrParam": "OZON" + "AqIndex": { + "Identyfikator stacji pomiarowej": 123, + "Data wykonania obliczeń indeksu": "2020-07-31 15:10:17", + "Nazwa kategorii indeksu": "Dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika st": "2020-07-31 14:00:00", + "Data wykonania obliczeń indeksu dla wskaźnika SO2": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika SO2": 0, + "Nazwa kategorii indeksu dla wskażnika SO2": "Bardzo dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika SO2": "2020-07-31 14:00:00", + "Data wykonania obliczeń indeksu dla wskaźnika NO2": "2020-07-31 14:00:00", + "Wartość indeksu dla wskaźnika NO2": 0, + "Nazwa kategorii indeksu dla wskażnika NO2": "Dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika NO2": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika CO": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika CO": 0, + "Nazwa kategorii indeksu dla wskażnika CO": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika CO": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM10": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika PM10": 0, + "Nazwa kategorii indeksu dla wskażnika PM10": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika PM10": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM2.5": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika PM2.5": 0, + "Nazwa kategorii indeksu dla wskażnika PM2.5": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika PM2.5": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika O3": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika O3": 1, + "Nazwa kategorii indeksu dla wskażnika O3": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika O3": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika C6H6": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika C6H6": 0, + "Nazwa kategorii indeksu dla wskażnika C6H6": "Bardzo dobry", + "Data wykonania obliczeń indeksu dla wskaźnika C6H6": "2020-07-31 14:00:00", + "Status indeksu ogólnego dla stacji pomiarowej": true, + "Kod zanieczyszczenia krytycznego": "OZON" + } } diff --git a/tests/components/gios/fixtures/sensors.json b/tests/components/gios/fixtures/sensors.json index db0cf2ff849..64cb9685f97 100644 --- a/tests/components/gios/fixtures/sensors.json +++ b/tests/components/gios/fixtures/sensors.json @@ -1,51 +1,65 @@ { "so2": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 4.35478 }, - { "date": "2020-07-31 14:00:00", "value": 4.25478 }, - { "date": "2020-07-31 13:00:00", "value": 4.34309 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 4.35478 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4.25478 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 4.34309 } ] }, "c6h6": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 0.23789 }, - { "date": "2020-07-31 14:00:00", "value": 0.22789 }, - { "date": "2020-07-31 13:00:00", "value": 0.21315 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 0.23789 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 0.22789 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 0.21315 } ] }, "co": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 251.874 }, - { "date": "2020-07-31 14:00:00", "value": 250.874 }, - { "date": "2020-07-31 13:00:00", "value": 251.097 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 251.874 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 250.874 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 251.097 } + ] + }, + "no": { + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 5.1 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4.0 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 5.2 } ] }, "no2": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 7.13411 }, - { "date": "2020-07-31 14:00:00", "value": 7.33411 }, - { "date": "2020-07-31 13:00:00", "value": 9.32578 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 7.13411 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 7.33411 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 9.32578 } + ] + }, + "nox": { + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 5.5 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 6.3 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 4.9 } ] }, "o3": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 95.7768 }, - { "date": "2020-07-31 14:00:00", "value": 93.7768 }, - { "date": "2020-07-31 13:00:00", "value": 89.4232 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 95.7768 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 93.7768 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 89.4232 } ] }, "pm2.5": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 4 }, - { "date": "2020-07-31 14:00:00", "value": 4 }, - { "date": "2020-07-31 13:00:00", "value": 5 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 4 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 5 } ] }, "pm10": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 16.8344 }, - { "date": "2020-07-31 14:00:00", "value": 17.8344 }, - { "date": "2020-07-31 13:00:00", "value": 20.8094 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 16.8344 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 17.8344 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 20.8094 } ] } } diff --git a/tests/components/gios/fixtures/station.json b/tests/components/gios/fixtures/station.json index 16cd824a489..1d112c0947b 100644 --- a/tests/components/gios/fixtures/station.json +++ b/tests/components/gios/fixtures/station.json @@ -1,72 +1,74 @@ [ { - "id": 672, - "stationId": 117, - "param": { - "paramName": "dwutlenek siarki", - "paramFormula": "SO2", - "paramCode": "SO2", - "idParam": 1 - } + "Identyfikator stanowiska": 672, + "Identyfikator stacji": 117, + "Wskaźnik": "dwutlenek siarki", + "Wskaźnik - wzór": "SO2", + "Wskaźnik - kod": "SO2", + "Id wskaźnika": 1 }, { - "id": 658, - "stationId": 117, - "param": { - "paramName": "benzen", - "paramFormula": "C6H6", - "paramCode": "C6H6", - "idParam": 10 - } + "Identyfikator stanowiska": 658, + "Identyfikator stacji": 117, + "Wskaźnik": "benzen", + "Wskaźnik - wzór": "C6H6", + "Wskaźnik - kod": "C6H6", + "Id wskaźnika": 10 }, { - "id": 660, - "stationId": 117, - "param": { - "paramName": "tlenek węgla", - "paramFormula": "CO", - "paramCode": "CO", - "idParam": 8 - } + "Identyfikator stanowiska": 660, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenek węgla", + "Wskaźnik - wzór": "CO", + "Wskaźnik - kod": "CO", + "Id wskaźnika": 8 }, { - "id": 665, - "stationId": 117, - "param": { - "paramName": "dwutlenek azotu", - "paramFormula": "NO2", - "paramCode": "NO2", - "idParam": 6 - } + "Identyfikator stanowiska": 664, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenek azotu", + "Wskaźnik - wzór": "NO", + "Wskaźnik - kod": "NO", + "Id wskaźnika": 16 }, { - "id": 667, - "stationId": 117, - "param": { - "paramName": "ozon", - "paramFormula": "O3", - "paramCode": "O3", - "idParam": 5 - } + "Identyfikator stanowiska": 665, + "Identyfikator stacji": 117, + "Wskaźnik": "dwutlenek azotu", + "Wskaźnik - wzór": "NO2", + "Wskaźnik - kod": "NO2", + "Id wskaźnika": 6 }, { - "id": 670, - "stationId": 117, - "param": { - "paramName": "pył zawieszony PM2.5", - "paramFormula": "PM2.5", - "paramCode": "PM2.5", - "idParam": 69 - } + "Identyfikator stanowiska": 666, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenki azotu", + "Wskaźnik - wzór": "NOx", + "Wskaźnik - kod": "NOx", + "Id wskaźnika": 7 }, { - "id": 14395, - "stationId": 117, - "param": { - "paramName": "pył zawieszony PM10", - "paramFormula": "PM10", - "paramCode": "PM10", - "idParam": 3 - } + "Identyfikator stanowiska": 667, + "Identyfikator stacji": 117, + "Wskaźnik": "ozon", + "Wskaźnik - wzór": "O3", + "Wskaźnik - kod": "O3", + "Id wskaźnika": 5 + }, + { + "Identyfikator stanowiska": 670, + "Identyfikator stacji": 117, + "Wskaźnik": "pył zawieszony PM2.5", + "Wskaźnik - wzór": "PM2.5", + "Wskaźnik - kod": "PM2.5", + "Id wskaźnika": 69 + }, + { + "Identyfikator stanowiska": 14395, + "Identyfikator stacji": 117, + "Wskaźnik": "pył zawieszony PM10", + "Wskaźnik - wzór": "PM10", + "Wskaźnik - kod": "PM10", + "Id wskaźnika": 3 } ] diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 890edc00482..722d14e3681 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -42,12 +42,24 @@ 'name': 'carbon monoxide', 'value': 251.874, }), + 'no': dict({ + 'id': 664, + 'index': None, + 'name': 'nitrogen monoxide', + 'value': 5.1, + }), 'no2': dict({ 'id': 665, 'index': 'good', 'name': 'nitrogen dioxide', 'value': 7.13411, }), + 'nox': dict({ + 'id': 666, + 'index': None, + 'name': 'nitrogen oxides', + 'value': 5.5, + }), 'o3': dict({ 'id': 667, 'index': 'good', diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index fd74cc222c8..b7ad5b2d51d 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -103,7 +103,7 @@ 'supported_features': 0, 'translation_key': 'c6h6', 'unique_id': '123-c6h6', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_benzene-state] @@ -112,7 +112,7 @@ 'attribution': 'Data provided by GIOŚ', 'friendly_name': 'Home Benzene', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_benzene', @@ -159,7 +159,7 @@ 'supported_features': 0, 'translation_key': 'co', 'unique_id': '123-co', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_carbon_monoxide-state] @@ -168,7 +168,7 @@ 'attribution': 'Data provided by GIOŚ', 'friendly_name': 'Home Carbon monoxide', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_carbon_monoxide', @@ -215,7 +215,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-no2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_nitrogen_dioxide-state] @@ -225,7 +225,7 @@ 'device_class': 'nitrogen_dioxide', 'friendly_name': 'Home Nitrogen dioxide', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_nitrogen_dioxide', @@ -302,6 +302,119 @@ 'state': 'good', }) # --- +# name: test_sensor[sensor.home_nitrogen_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_nitrogen_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nitrogen monoxide', + 'platform': 'gios', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-no', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by GIOŚ', + 'device_class': 'nitrogen_monoxide', + 'friendly_name': 'Home Nitrogen monoxide', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.1', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_oxides-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_nitrogen_oxides', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nitrogen oxides', + 'platform': 'gios', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nox', + 'unique_id': '123-nox', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_oxides-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by GIOŚ', + 'friendly_name': 'Home Nitrogen oxides', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_oxides', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- # name: test_sensor[sensor.home_ozone-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -339,7 +452,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-o3', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_ozone-state] @@ -349,7 +462,7 @@ 'device_class': 'ozone', 'friendly_name': 'Home Ozone', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_ozone', @@ -463,7 +576,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-pm10', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_pm10-state] @@ -473,7 +586,7 @@ 'device_class': 'pm10', 'friendly_name': 'Home PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_pm10', @@ -587,7 +700,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-pm25', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_pm2_5-state] @@ -597,7 +710,7 @@ 'device_class': 'pm25', 'friendly_name': 'Home PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_pm2_5', @@ -711,7 +824,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '123-so2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.home_sulphur_dioxide-state] @@ -721,7 +834,7 @@ 'device_class': 'sulphur_dioxide', 'friendly_name': 'Home Sulphur dioxide', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.home_sulphur_dioxide', diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 2abdf724f61..e77e61346b6 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -120,7 +120,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -139,7 +138,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -670,3 +668,31 @@ async def test_async_get_image( HomeAssistantError, match="Stream source is not supported by go2rtc" ): await async_get_image(hass, camera.entity_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_generic_workaround( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test workaround for generic integration cameras.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + rest_client.get_jpeg_snapshot.return_value = image_bytes + camera.set_stream_source("https://my_stream_url.m3u8") + + with patch.object(camera.platform.platform_data, "platform_name", "generic"): + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], + ) diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 1e7e48437cd..791b93185d2 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -22,6 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID, @@ -218,7 +219,9 @@ async def test_discovered_dhcp( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" + ip="1.2.3.4", + macaddress=dr.format_mac(MOCK_MAC_ADDR).replace(":", ""), + hostname="mock_hostname", ), ) assert result["type"] is FlowResultType.FORM @@ -281,7 +284,9 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" + ip="1.2.3.4", + macaddress=dr.format_mac(MOCK_MAC_ADDR).replace(":", ""), + hostname="mock_hostname", ), ) assert result2["type"] is FlowResultType.ABORT @@ -291,7 +296,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.2.3.4", macaddress="00:00:00:00:00:00", hostname="mock_hostname" + ip="1.2.3.4", macaddress="000000000000", hostname="mock_hostname" ), ) assert result3["type"] is FlowResultType.ABORT diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 2dba083185d..fc840695081 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -47,6 +47,7 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) +from homeassistant.helpers.entity import EntityPlatformState from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig @@ -160,6 +161,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() # This should not show up in the sync request @@ -306,6 +308,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> light.entity_id = entity.entity_id light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() config = MockConfig(should_expose=lambda _: True, entity_config={}) @@ -402,6 +405,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() light2 = DemoLight( @@ -412,6 +416,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light2.entity_id = "light.another_light" light2._attr_device_info = None light2._attr_name = "Another Light" + light2._platform_state = EntityPlatformState.ADDED light2.async_write_ha_state() light3 = DemoLight(None, "Color temp Light", state=True, ct=2500, brightness=200) @@ -420,6 +425,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light3.entity_id = "light.color_temp_light" light3._attr_device_info = None light3._attr_name = "Color temp Light" + light3._platform_state = EntityPlatformState.ADDED light3.async_write_ha_state() events = async_capture_events(hass, EVENT_QUERY_RECEIVED) @@ -909,6 +915,7 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: light._available = False light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() events = async_capture_events(hass, EVENT_SYNC_RECEIVED) @@ -994,19 +1001,20 @@ async def test_device_class_switch( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a cover entity syncs to the correct device type.""" - sensor = DemoSwitch( + switch = DemoSwitch( None, - "Demo Sensor", + "Demo switch", state=False, assumed=False, device_class=device_class, ) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "switch.demo_sensor" - sensor._attr_device_info = None - sensor._attr_name = "Demo Sensor" - sensor.async_write_ha_state() + switch.hass = hass + switch.platform = MockEntityPlatform(hass) + switch.entity_id = "switch.demo_switch" + switch._attr_device_info = None + switch._attr_name = "Demo Switch" + switch._platform_state = EntityPlatformState.ADDED + switch.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1024,8 +1032,8 @@ async def test_device_class_switch( "devices": [ { "attributes": {}, - "id": "switch.demo_sensor", - "name": {"name": "Demo Sensor"}, + "id": "switch.demo_switch", + "name": {"name": "Demo Switch"}, "traits": ["action.devices.traits.OnOff"], "type": google_type, "willReportState": False, @@ -1049,15 +1057,16 @@ async def test_device_class_binary_sensor( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a binary entity syncs to the correct device type.""" - sensor = DemoBinarySensor( - None, "Demo Sensor", state=False, device_class=device_class + binary_sensor = DemoBinarySensor( + None, "Demo Binary Sensor", state=False, device_class=device_class ) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "binary_sensor.demo_sensor" - sensor._attr_device_info = None - sensor._attr_name = "Demo Sensor" - sensor.async_write_ha_state() + binary_sensor.hass = hass + binary_sensor.platform = MockEntityPlatform(hass) + binary_sensor.entity_id = "binary_sensor.demo_binary_sensor" + binary_sensor._attr_device_info = None + binary_sensor._attr_name = "Demo Binary Sensor" + binary_sensor._platform_state = EntityPlatformState.ADDED + binary_sensor.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1078,8 +1087,8 @@ async def test_device_class_binary_sensor( "queryOnlyOpenClose": True, "discreteOnlyOpenClose": True, }, - "id": "binary_sensor.demo_sensor", - "name": {"name": "Demo Sensor"}, + "id": "binary_sensor.demo_binary_sensor", + "name": {"name": "Demo Binary Sensor"}, "traits": ["action.devices.traits.OpenClose"], "type": google_type, "willReportState": False, @@ -1106,13 +1115,14 @@ async def test_device_class_cover( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a cover entity syncs to the correct device type.""" - sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "cover.demo_sensor" - sensor._attr_device_info = None - sensor._attr_name = "Demo Sensor" - sensor.async_write_ha_state() + cover = DemoCover(None, hass, "Demo Cover", device_class=device_class) + cover.hass = hass + cover.platform = MockEntityPlatform(hass) + cover.entity_id = "cover.demo_cover" + cover._attr_device_info = None + cover._attr_name = "Demo Cover" + cover._platform_state = EntityPlatformState.ADDED + cover.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1130,8 +1140,8 @@ async def test_device_class_cover( "devices": [ { "attributes": {"discreteOnlyOpenClose": True}, - "id": "cover.demo_sensor", - "name": {"name": "Demo Sensor"}, + "id": "cover.demo_cover", + "name": {"name": "Demo Cover"}, "traits": [ "action.devices.traits.StartStop", "action.devices.traits.OpenClose", @@ -1157,11 +1167,12 @@ async def test_device_media_player( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a binary entity syncs to the correct device type.""" - sensor = AbstractDemoPlayer("Demo", device_class=device_class) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "media_player.demo" - sensor.async_write_ha_state() + media_player = AbstractDemoPlayer("Demo", device_class=device_class) + media_player.hass = hass + media_player.platform = MockEntityPlatform(hass) + media_player.entity_id = "media_player.demo" + media_player._platform_state = EntityPlatformState.ADDED + media_player.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1182,8 +1193,8 @@ async def test_device_media_player( "supportActivityState": True, "supportPlaybackState": True, }, - "id": sensor.entity_id, - "name": {"name": sensor.name}, + "id": media_player.entity_id, + "name": {"name": media_player.name}, "traits": [ "action.devices.traits.OnOff", "action.devices.traits.MediaState", @@ -1455,6 +1466,7 @@ async def test_sync_message_recovery( light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() hass.states.async_set( diff --git a/tests/components/google_assistant_sdk/test_application_credentials.py b/tests/components/google_assistant_sdk/test_application_credentials.py new file mode 100644 index 00000000000..e7811677c53 --- /dev/null +++ b/tests/components/google_assistant_sdk/test_application_credentials.py @@ -0,0 +1,36 @@ +"""Test the Google Assistant SDK application_credentials.""" + +import pytest + +from homeassistant import setup +from homeassistant.components.google_assistant_sdk.application_credentials import ( + async_get_description_placeholders, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("additional_components", "external_url", "expected_redirect_uri"), + [ + ([], "https://example.com", "https://example.com/auth/external/callback"), + ([], None, "https://YOUR_DOMAIN:PORT/auth/external/callback"), + (["my"], "https://example.com", "https://my.home-assistant.io/redirect/oauth"), + ], +) +async def test_description_placeholders( + hass: HomeAssistant, + additional_components: list[str], + external_url: str | None, + expected_redirect_uri: str, +) -> None: + """Test description placeholders.""" + for component in additional_components: + assert await setup.async_setup_component(hass, component, {}) + hass.config.external_url = external_url + placeholders = await async_get_description_placeholders(hass) + assert placeholders == { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": expected_redirect_uri, + } diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 9bb08c802c2..caddf9ba797 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -116,6 +116,25 @@ async def test_expired_token_refresh_failure( assert entries[0].state is expected_state +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_setup_client_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup handling aiohttp.ClientError.""" + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + exc=aiohttp.ClientError, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( ("configured_language_code", "expected_language_code"), [("", "en-US"), ("en-US", "en-US"), ("es-ES", "es-ES")], diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index b8e37d0f3b8..6307a7586d2 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -17,7 +17,6 @@ from homeassistant.components.backup import ( ) from homeassistant.components.google_drive import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID @@ -66,8 +65,7 @@ async def setup_integration( config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Set up Google Drive and backup integrations.""" - async_initialize_backup(hass) + """Set up Google Drive integration.""" config_entry.add_to_hass(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) mock_api.list_files = AsyncMock( diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 18b3c8e07f0..57119ce0ff1 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -1,43 +1,16 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import Mock - from google.genai.errors import APIError, ClientError -import httpx API_ERROR_500 = APIError( 500, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "Internal Server Error", - "status": "internal-error", - } - ), - ), + {"message": "Internal Server Error", "status": "internal-error"}, ) CLIENT_ERROR_BAD_REQUEST = ClientError( 400, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "Bad Request", - "status": "invalid-argument", - } - ), - ), + {"message": "Bad Request", "status": "invalid-argument"}, ) CLIENT_ERROR_API_KEY_INVALID = ClientError( 400, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "'reason': API_KEY_INVALID", - "status": "unauthorized", - } - ), - ), + {"message": "'reason': API_KEY_INVALID", "status": "unauthorized"}, ) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 36d99cd2764..b19482957b2 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -7,7 +7,10 @@ import pytest from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_TTS_NAME, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API @@ -28,13 +31,36 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "api_key": "bla", }, version=2, + minor_version=3, subentries_data=[ { "data": {}, "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, + "subentry_id": "ulid-conversation", "unique_id": None, - } + }, + { + "data": {}, + "subentry_type": "stt", + "title": DEFAULT_STT_NAME, + "subentry_id": "ulid-stt", + "unique_id": None, + }, + { + "data": {}, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "subentry_id": "ulid-tts", + "unique_id": None, + }, + { + "data": {}, + "subentry_type": "ai_task_data", + "title": DEFAULT_AI_TASK_NAME, + "subentry_id": "ulid-ai-task", + "unique_id": None, + }, ], ) entry.runtime_data = Mock() @@ -94,19 +120,26 @@ async def setup_ha(hass: HomeAssistant) -> None: @pytest.fixture -def mock_send_message_stream() -> Generator[AsyncMock]: +def mock_chat_create() -> Generator[AsyncMock]: """Mock stream response.""" async def mock_generator(stream): for value in stream: yield value - with patch( - "google.genai.chats.AsyncChat.send_message_stream", - AsyncMock(), - ) as mock_send_message_stream: - mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( - mock_send_message_stream.return_value.pop(0) - ) + mock_send_message_stream = AsyncMock() + mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( + mock_send_message_stream.return_value.pop(0) + ) - yield mock_send_message_stream + with patch( + "google.genai.chats.AsyncChats.create", + return_value=AsyncMock(send_message_stream=mock_send_message_stream), + ) as mock_create: + yield mock_create + + +@pytest.fixture +def mock_send_message_stream(mock_chat_create) -> Generator[AsyncMock]: + """Mock stream response.""" + return mock_chat_create.return_value.send_message_stream diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index a31827c7acc..bceb12a9256 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,17 +5,51 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-2.5-flash', - 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 1500, - 'prompt': 'Speak like a pirate', - 'recommended': False, - 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, + }), + 'subentries': dict({ + 'ulid-ai-task': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-ai-task', + 'subentry_type': 'ai_task_data', + 'title': 'Google AI Task', + 'unique_id': None, + }), + 'ulid-conversation': dict({ + 'data': dict({ + 'chat_model': 'models/gemini-2.5-flash', + 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'max_tokens': 3000, + 'prompt': 'Speak like a pirate', + 'recommended': False, + 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'subentry_id': 'ulid-conversation', + 'subentry_type': 'conversation', + 'title': 'Google AI Conversation', + 'unique_id': None, + }), + 'ulid-stt': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-stt', + 'subentry_type': 'stt', + 'title': 'Google AI STT', + 'unique_id': None, + }), + 'ulid-tts': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-tts', + 'subentry_type': 'tts', + 'title': 'Google AI TTS', + 'unique_id': None, + }), }), 'title': 'Google Generative AI Conversation', }) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f89871ff131..c2568159c79 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,4 +1,124 @@ # serializer version: 1 +# name: test_devices + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-conversation', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-stt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI STT', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-ai-task', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI Task', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-tts', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash-preview-tts', + 'model_id': None, + 'name': 'Google AI TTS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- # name: test_generate_content_file_processing_succeeds list([ tuple( @@ -8,8 +128,14 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), - File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File( + name='doorbell_snapshot.jpg', + state= + ), + File( + name='context.txt', + state= + ), ]), 'model': 'models/gemini-2.5-flash', }), @@ -25,8 +151,14 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - b'some file', - b'some file', + File( + name='doorbell_snapshot.jpg', + state= + ), + File( + name='context.txt', + state= + ), ]), 'model': 'models/gemini-2.5-flash', }), diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py new file mode 100644 index 00000000000..6326bd94ad9 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -0,0 +1,218 @@ +"""Test AI Task platform of Google Generative AI Conversation integration.""" + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from google.genai.types import File, FileState, GenerateContentResponse +import pytest +import voluptuous as vol + +from homeassistant.components import ai_task, media_source +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from tests.common import MockConfigEntry +from tests.components.conversation import ( + MockChatLog, + mock_chat_log, # noqa: F401 +) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, + mock_chat_create: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test generating data.""" + entity_id = "ai_task.google_ai_task" + + # Ensure it's linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hi there!"}], + "role": "model", + }, + } + ], + ), + ], + ] + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + ) + assert result.data == "Hi there!" + + # Test with attachments + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hi there!"}], + "role": "model", + }, + } + ], + ), + ], + ] + file1 = File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE) + file2 = File(name="context.txt", state=FileState.ACTIVE) + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch( + "google.genai.files.Files.upload", + side_effect=[file1, file2], + ) as mock_upload, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) + + outgoing_message = mock_send_message_stream.mock_calls[1][2]["message"] + assert outgoing_message == ["Test prompt", file1, file2] + + assert result.data == "Hi there!" + assert len(mock_upload.mock_calls) == 2 + assert mock_upload.mock_calls[0][2]["file"] == Path("doorbell_snapshot.jpg") + assert mock_upload.mock_calls[1][2]["file"] == Path("context.txt") + + # Test attachments require play media with a path + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=None, + ), + ], + ), + pytest.raises( + HomeAssistantError, match="Only local attachments are currently supported" + ), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + # Test with structure + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": '{"characters": ["Mario", "Luigi"]}'}], + "role": "model", + }, + } + ], + ), + ], + ] + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Give me 2 mario characters", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + assert result.data == {"characters": ["Mario", "Luigi"]} + + assert len(mock_chat_create.mock_calls) == 3 + config = mock_chat_create.mock_calls[-1][2]["config"] + assert config.response_mime_type == "application/json" + assert config.response_schema == { + "properties": {"characters": {"items": {"type": "STRING"}, "type": "ARRAY"}}, + "required": ["characters"], + "type": "OBJECT", + } + # Raise error on invalid JSON response + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "INVALID JSON RESPONSE"}], + "role": "model", + }, + } + ], + ), + ], + ] + with pytest.raises(HomeAssistantError): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + structure=vol.Schema({vol.Required("bla"): str}), + ) diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index e02d85e41c4..52def1d06bb 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -1,14 +1,12 @@ """Test the Google Generative AI Conversation config flow.""" +from typing import Any from unittest.mock import Mock, patch import pytest from requests.exceptions import Timeout from homeassistant import config_entries -from homeassistant.components.google_generative_ai_conversation.config_flow import ( - RECOMMENDED_OPTIONS, -) from homeassistant.components.google_generative_ai_conversation.const import ( CONF_CHAT_MODEL, CONF_DANGEROUS_BLOCK_THRESHOLD, @@ -22,13 +20,22 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME @@ -43,34 +50,36 @@ from tests.common import MockConfigEntry def get_models_pager(): """Return a generator that yields the models.""" model_25_flash = Mock( - display_name="Gemini 2.5 Flash", supported_actions=["generateContent"], ) model_25_flash.name = "models/gemini-2.5-flash" model_20_flash = Mock( - display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], ) model_20_flash.name = "models/gemini-2.0-flash" model_15_flash = Mock( - display_name="Gemini 1.5 Flash", supported_actions=["generateContent"], ) model_15_flash.name = "models/gemini-1.5-flash-latest" model_15_pro = Mock( - display_name="Gemini 1.5 Pro", supported_actions=["generateContent"], ) model_15_pro.name = "models/gemini-1.5-pro-latest" + model_25_flash_tts = Mock( + supported_actions=["generateContent"], + ) + model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts" + async def models_pager(): yield model_25_flash yield model_20_flash yield model_15_flash yield model_15_pro + yield model_25_flash_tts return models_pager() @@ -115,10 +124,28 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["subentries"] == [ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -147,22 +174,35 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_creating_conversation_subentry( +@pytest.mark.parametrize( + ("subentry_type", "options"), + [ + ("conversation", RECOMMENDED_CONVERSATION_OPTIONS), + ("stt", RECOMMENDED_STT_OPTIONS), + ("tts", RECOMMENDED_TTS_OPTIONS), + ("ai_task_data", RECOMMENDED_AI_TASK_OPTIONS), + ], +) +async def test_creating_subentry( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, + subentry_type: str, + options: dict[str, Any], ) -> None: - """Test creating a conversation subentry.""" + """Test creating a subentry.""" + old_subentries = set(mock_config_entry.subentries) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "conversation"), + (mock_config_entry.entry_id, subentry_type), context={"source": config_entries.SOURCE_USER}, ) - assert result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "set_options" assert not result["errors"] @@ -172,17 +212,170 @@ async def test_creating_conversation_subentry( ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, + result["data_schema"]({CONF_NAME: "Mock name", **options}), ) await hass.async_block_till_done() + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Mock name" + assert result2["data"] == expected_options - processed_options = RECOMMENDED_OPTIONS.copy() - processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 - assert result2["data"] == processed_options + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" + + +@pytest.mark.parametrize( + ("subentry_type", "recommended_model", "options"), + [ + ( + "conversation", + RECOMMENDED_CHAT_MODEL, + { + CONF_PROMPT: "You are Mario", + CONF_LLM_HASS_API: ["assist"], + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + ), + ( + "stt", + RECOMMENDED_STT_MODEL, + { + CONF_PROMPT: "Transcribe this", + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_STT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "tts", + RECOMMENDED_TTS_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_TTS_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "ai_task_data", + RECOMMENDED_CHAT_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ], +) +async def test_creating_subentry_custom_options( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, + subentry_type: str, + recommended_model: str, + options: dict[str, Any], +) -> None: + """Test creating a subentry with custom options.""" + old_subentries = set(mock_config_entry.subentries) + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, subentry_type), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "set_options" + assert not result["errors"] + + # Uncheck recommended to show custom options + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + result["data_schema"]({CONF_RECOMMENDED: False}), + ) + assert result2["type"] is FlowResultType.FORM + + # Find the schema key for CONF_CHAT_MODEL and check its default + schema_dict = result2["data_schema"].schema + chat_model_key = next(key for key in schema_dict if key.schema == CONF_CHAT_MODEL) + assert chat_model_key.default() == recommended_model + models_in_selector = [ + opt["value"] for opt in schema_dict[chat_model_key].config["options"] + ] + assert recommended_model in models_in_selector + + # Submit the form + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result3 = await hass.config_entries.subentries.async_configure( + result2["flow_id"], + result2["data_schema"]({CONF_NAME: "Mock name", **options}), + ) + await hass.async_block_till_done() + + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Mock name" + assert result3["data"] == expected_options + + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" async def test_creating_conversation_subentry_not_loaded( @@ -190,8 +383,9 @@ async def test_creating_conversation_subentry_not_loaded( mock_init_component: None, mock_config_entry: MockConfigEntry, ) -> None: - """Test creating a conversation subentry.""" + """Test that subentry fails to init if entry not loaded.""" await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index ff9694257f9..ab8c10e933b 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -195,10 +195,13 @@ async def test_function_call( "response": { "result": "Test response", }, + "scheduling": None, + "will_continue": None, }, "inline_data": None, "text": None, "thought": None, + "thought_signature": None, "video_metadata": None, } @@ -359,7 +362,7 @@ async def test_empty_response( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - ERROR_GETTING_RESPONSE + "Unable to get response" ) diff --git a/tests/components/google_generative_ai_conversation/test_diagnostics.py b/tests/components/google_generative_ai_conversation/test_diagnostics.py index ebc1b5e52a5..0f193238669 100644 --- a/tests/components/google_generative_ai_conversation/test_diagnostics.py +++ b/tests/components/google_generative_ai_conversation/test_diagnostics.py @@ -35,10 +35,10 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - mock_config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: RECOMMENDED_TEMPERATURE, diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 8de678213c2..fbd52dc9245 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,5 +1,6 @@ """Tests for the Google Generative AI Conversation integration.""" +from typing import Any from unittest.mock import AsyncMock, Mock, mock_open, patch from google.genai.types import File, FileState @@ -7,12 +8,29 @@ import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion -from homeassistant.components.google_generative_ai_conversation.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_TITLE, + DEFAULT_TTS_NAME, + DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, + RECOMMENDED_STT_OPTIONS, + RECOMMENDED_TTS_OPTIONS, +) +from homeassistant.config_entries import ( + ConfigEntryDisabler, + ConfigEntryState, + ConfigSubentryData, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID @@ -71,11 +89,13 @@ async def test_generate_content_service_with_image( ) as mock_generate, patch( "google.genai.files.Files.upload", - return_value=b"some file", + side_effect=[ + File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), + File(name="context.txt", state=FileState.ACTIVE), + ], ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), - patch("builtins.open", mock_open(read_data="this is an image")), patch("mimetypes.guess_type", return_value=["image/jpeg"]), ): response = await hass.services.async_call( @@ -83,7 +103,7 @@ async def test_generate_content_service_with_image( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + "filenames": ["doorbell_snapshot.jpg", "context.txt"], }, blocking=True, return_response=True, @@ -137,7 +157,7 @@ async def test_generate_content_file_processing_succeeds( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + "filenames": ["doorbell_snapshot.jpg", "context.txt"], }, blocking=True, return_response=True, @@ -199,7 +219,7 @@ async def test_generate_content_file_processing_fails( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + "filenames": ["doorbell_snapshot.jpg", "context.txt"], }, blocking=True, return_response=True, @@ -392,7 +412,7 @@ async def test_load_entry_with_unloaded_entries( assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -468,14 +488,46 @@ async def test_migration_from_v1_to_v2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 4 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 5 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -492,8 +544,12 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" @@ -511,9 +567,238 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } -async def test_migration_from_v1_to_v2_with_multiple_keys( +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 5 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + +async def test_migration_from_v1_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -590,20 +875,38 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 + assert entry.minor_version == 4 assert not entry.options - assert len(entry.subentries) == 1 + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 4 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + subentry = list(entry.subentries.values())[1] + assert subentry.subentry_type == "tts" + assert subentry.data == RECOMMENDED_TTS_OPTIONS + assert subentry.title == DEFAULT_TTS_NAME + subentry = list(entry.subentries.values())[2] + assert subentry.subentry_type == "ai_task_data" + assert subentry.data == RECOMMENDED_AI_TASK_OPTIONS + assert subentry.title == DEFAULT_AI_TASK_NAME + subentry = list(entry.subentries.values())[3] + assert subentry.subentry_type == "stt" + assert subentry.data == RECOMMENDED_STT_OPTIONS + assert subentry.title == DEFAULT_STT_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == { + entry.entry_id: {list(entry.subentries.values())[0].subentry_id} + } -async def test_migration_from_v1_to_v2_with_same_keys( +async def test_migration_from_v1_with_same_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -679,14 +982,46 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 4 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 5 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -703,8 +1038,12 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" @@ -722,3 +1061,525 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +@pytest.mark.parametrize( + ("device_changes", "extra_subentries", "expected_device_subentries"), + [ + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b0: + # Wrong device registry, no TTS subentry + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: + # Wrong device registry, TTS subentry created + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 + # or later: Correct device registry, TTS subentry created + ( + {}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {"mock_id_1"}}, + ), + ], +) +async def test_migration_from_v2_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_changes: dict[str, str], + extra_subentries: list[ConfigSubentryData], + expected_device_subentries: dict[str, set[str | None]], +) -> None: + """Test migration from version 2.1. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1 and add AI Task and STT subentries: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) + - Add AI Task subentry (Added in version 2.3) + - Add STT subentry (Added in version 2.3) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Google Generative AI", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Google Generative AI 2", + unique_id=None, + ), + *extra_subentries, + ], + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Google Generative AI", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device(device_1.id, **device_changes) + assert device_1.config_entries_subentries == expected_device_subentries + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Google Generative AI 2", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 5 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Assert that devices are created correctly.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices == snapshot + + +async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: + """Test migration from version 2.2.""" + # Create a v2.2 config entry with conversation and TTS subentries + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + version=2, + minor_version=2, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "data": RECOMMENDED_TTS_OPTIONS, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 2 + + # Run setup to trigger migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is True + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == 4 + + # Check we now have conversation, tts, stt, and ai_task_data subentries + assert len(entry.subentries) == 4 + + subentries = { + subentry.subentry_type: subentry for subentry in entry.subentries.values() + } + assert "conversation" in subentries + assert "tts" in subentries + assert "ai_task_data" in subentries + + # Find and verify the ai_task_data subentry + ai_task_subentry = subentries["ai_task_data"] + assert ai_task_subentry is not None + assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME + assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS + + # Find and verify the stt subentry + ai_task_subentry = subentries["stt"] + assert ai_task_subentry is not None + assert ai_task_subentry.title == DEFAULT_STT_NAME + assert ai_task_subentry.data == RECOMMENDED_STT_OPTIONS + + # Verify conversation subentry is still there and unchanged + conversation_subentry = subentries["conversation"] + assert conversation_subentry is not None + assert conversation_subentry.title == DEFAULT_CONVERSATION_NAME + assert conversation_subentry.data == RECOMMENDED_CONVERSATION_OPTIONS + + # Verify TTS subentry is still there and unchanged + tts_subentry = subentries["tts"] + assert tts_subentry is not None + assert tts_subentry.title == DEFAULT_TTS_NAME + assert tts_subentry.data == RECOMMENDED_TTS_OPTIONS + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 4, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 3, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 2.3.""" + # Create a v2.3 config entry with conversation and TTS subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=3, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "data": RECOMMENDED_TTS_OPTIONS, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="google_generative_ai_conversation", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 + assert len(mock_config_entry.subentries) == 2 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration diff --git a/tests/components/google_generative_ai_conversation/test_stt.py b/tests/components/google_generative_ai_conversation/test_stt.py new file mode 100644 index 00000000000..90c58ebba16 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_stt.py @@ -0,0 +1,303 @@ +"""Tests for the Google Generative AI Conversation STT entity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Generator +from unittest.mock import AsyncMock, Mock, patch + +from google.genai import types +import pytest + +from homeassistant.components import stt +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + DOMAIN, + RECOMMENDED_STT_MODEL, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from . import API_ERROR_500, CLIENT_ERROR_BAD_REQUEST + +from tests.common import MockConfigEntry + +TEST_CHAT_MODEL = "models/gemini-2.5-flash" +TEST_PROMPT = "Please transcribe the audio." + + +async def _async_get_audio_stream(data: bytes) -> AsyncIterable[bytes]: + """Yield the audio data.""" + yield data + + +@pytest.fixture +def mock_genai_client() -> Generator[AsyncMock]: + """Mock genai.Client.""" + client = Mock() + client.aio.models.get = AsyncMock() + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "This is a test transcription."}], + "role": "model", + } + } + ] + ) + ) + with patch( + "homeassistant.components.google_generative_ai_conversation.Client", + return_value=client, + ) as mock_client: + yield mock_client.return_value + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Set up the test environment.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + + sub_entry = ConfigSubentry( + data={ + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + CONF_PROMPT: TEST_PROMPT, + }, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_entity_properties(hass: HomeAssistant) -> None: + """Test STT entity properties.""" + entity: stt.SpeechToTextEntity = hass.data[stt.DOMAIN].get_entity( + "stt.google_ai_stt" + ) + assert entity is not None + assert isinstance(entity.supported_languages, list) + assert stt.AudioFormats.WAV in entity.supported_formats + assert stt.AudioFormats.OGG in entity.supported_formats + assert stt.AudioCodecs.PCM in entity.supported_codecs + assert stt.AudioCodecs.OPUS in entity.supported_codecs + assert stt.AudioBitRates.BITRATE_16 in entity.supported_bit_rates + assert stt.AudioSampleRates.SAMPLERATE_16000 in entity.supported_sample_rates + assert stt.AudioChannels.CHANNEL_MONO in entity.supported_channels + + +@pytest.mark.parametrize( + ("audio_format", "call_convert_to_wav"), + [ + (stt.AudioFormats.WAV, True), + (stt.AudioFormats.OGG, False), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_success( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + audio_format: stt.AudioFormats, + call_convert_to_wav: bool, +) -> None: + """Test STT processing audio stream successfully.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=audio_format, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + with patch( + "homeassistant.components.google_generative_ai_conversation.stt.convert_to_wav", + return_value=b"converted_wav_bytes", + ) as mock_convert_to_wav: + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.SUCCESS + assert result.text == "This is a test transcription." + + if call_convert_to_wav: + mock_convert_to_wav.assert_called_once_with( + b"test_audio_bytes", "audio/L16;rate=16000" + ) + else: + mock_convert_to_wav.assert_not_called() + + mock_genai_client.aio.models.generate_content.assert_called_once() + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == TEST_CHAT_MODEL + + contents = call_args.kwargs["contents"] + assert contents[0] == TEST_PROMPT + assert isinstance(contents[1], types.Part) + assert contents[1].inline_data.mime_type == f"audio/{audio_format.value}" + if call_convert_to_wav: + assert contents[1].inline_data.data == b"converted_wav_bytes" + else: + assert contents[1].inline_data.data == b"test_audio_bytes" + + +@pytest.mark.parametrize( + "side_effect", + [ + API_ERROR_500, + CLIENT_ERROR_BAD_REQUEST, + ValueError("Test value error"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_api_error( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + side_effect: Exception, +) -> None: + """Test STT processing audio stream with API errors.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.side_effect = side_effect + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_empty_response( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test STT processing with an empty response from the API.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.return_value = ( + types.GenerateContentResponse(candidates=[]) + ) + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_prompt( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default prompt is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no prompt + sub_entry = ConfigSubentry( + data={CONF_CHAT_MODEL: TEST_CHAT_MODEL}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + contents = call_args.kwargs["contents"] + assert contents[0] == DEFAULT_STT_PROMPT + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_model( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default model is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no model + sub_entry = ConfigSubentry( + data={CONF_PROMPT: TEST_PROMPT}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == RECOMMENDED_STT_MODEL diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 4f197f0535f..87fc4fe8a76 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -9,30 +9,37 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from google.genai import types +from google.genai.errors import APIError import pytest from homeassistant.components import tts -from homeassistant.components.google_generative_ai_conversation.tts import ( - ATTR_MODEL, +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, DOMAIN, - RECOMMENDED_TTS_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, ) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from . import API_ERROR_500 - from tests.common import MockConfigEntry, async_mock_service from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +API_ERROR_500 = APIError("test", response_json={}) +TEST_CHAT_MODEL = "models/some-tts-model" + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: @@ -63,20 +70,22 @@ def mock_genai_client() -> Generator[AsyncMock]: """Mock genai_client.""" client = Mock() client.aio.models.get = AsyncMock() - client.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) ) - ) - ), + ), + ) ) ) with patch( @@ -90,17 +99,29 @@ def mock_genai_client() -> Generator[AsyncMock]: async def setup_fixture( hass: HomeAssistant, config: dict[str, Any], - request: pytest.FixtureRequest, mock_genai_client: AsyncMock, ) -> None: """Set up the test environment.""" - if request.param == "mock_setup": - await mock_setup(hass, config) - if request.param == "mock_config_entry_setup": - await mock_config_entry_setup(hass, config) - else: - raise RuntimeError("Invalid setup fixture") + config_entry = MockConfigEntry(domain=DOMAIN, data=config, version=2) + config_entry.add_to_hass(hass) + sub_entry = ConfigSubentry( + data={ + tts.CONF_LANG: "en-US", + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + }, + subentry_type="tts", + title="Google AI TTS", + subentry_id="test_subentry_tts_id", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() @@ -112,105 +133,38 @@ def config_fixture() -> dict[str, Any]: } -async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock setup.""" - assert await async_setup_component( - hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} - ) - - -async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock config entry setup.""" - default_config = {tts.CONF_LANG: "en-US"} - config_entry = MockConfigEntry( - domain=DOMAIN, data=default_config | config, version=2 - ) - - client_mock = Mock() - client_mock.models.get = None - client_mock.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), - ) - ) - ), - ) - ) - config_entry.runtime_data = client_mock - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - - @pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), + "service_data", [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, - }, - ), + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, ], - indirect=["setup"], ) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, service_data: dict[str, Any], ) -> None: """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.reset_mock() await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -221,10 +175,9 @@ async def test_tts_service_speak( == HTTPStatus.OK ) voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr") - model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=model_id, + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], @@ -233,109 +186,52 @@ async def test_tts_service_speak( prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "de-DE", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "it-IT", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_lang_config( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with languages in the config.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak_error( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], ) -> None: """Test service call with HTTP response 500.""" + service_data = { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + } tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500 + tts_entity._genai_client.aio.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.side_effect = API_ERROR_500 await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -346,70 +242,39 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE) + + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], speech_config=types.SpeechConfig( voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_without_options( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with HTTP response 200.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr") + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index caed4a5c469..2410b5dbbde 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -183,7 +183,7 @@ async def test_gvh5106(hass: HomeAssistant) -> None: pm25_sensor_attributes = pm25_sensor.attributes assert pm25_sensor.state == "0" assert pm25_sensor_attributes[ATTR_FRIENDLY_NAME] == "H5106 4E05 Pm25" - assert pm25_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "µg/m³" + assert pm25_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "μg/m³" assert pm25_sensor_attributes[ATTR_STATE_CLASS] == "measurement" assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 30adae2fd2a..b1bb6e5d7bb 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -44,7 +44,8 @@ from tests.typing import WebSocketGenerator {}, ), ("fan", "on", "on", {}, {}, {}, {}), - ("light", "on", "on", {}, {}, {}, {}), + ("light", "on", "on", {}, {}, {"all": False}, {}), + ("light", "on", "on", {}, {"all": True}, {"all": True}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), ("notify", STATE_UNKNOWN, "2021-01-01T23:59:59.123+00:00", {}, {}, {}, {}), ("media_player", "on", "on", {}, {}, {}, {}), @@ -57,7 +58,8 @@ from tests.typing import WebSocketGenerator {"type": "sum"}, {}, ), - ("switch", "on", "on", {}, {}, {}, {}), + ("switch", "on", "on", {}, {}, {"all": False}, {}), + ("switch", "on", "on", {}, {"all": True}, {"all": True}, {}), ], ) async def test_config_flow( @@ -315,11 +317,11 @@ async def test_options( ("group_type", "extra_options", "extra_options_after", "advanced"), [ ("light", {"all": False}, {"all": False}, False), - ("light", {"all": True}, {"all": True}, False), + ("light", {"all": True}, {"all": False}, False), ("light", {"all": False}, {"all": False}, True), ("light", {"all": True}, {"all": False}, True), ("switch", {"all": False}, {"all": False}, False), - ("switch", {"all": True}, {"all": True}, False), + ("switch", {"all": True}, {"all": False}, False), ("switch", {"all": False}, {"all": False}, True), ("switch", {"all": True}, {"all": False}, True), ], diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index e3a01c05eca..49ad71f5b6b 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -199,7 +199,8 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No }, }, ), - ] + ], + any_order=True, ) diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 80e09d823cc..331d2ccf36a 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -10,6 +10,7 @@ from habiticalib import ( HabiticaContentResponse, HabiticaErrorResponse, HabiticaGroupMembersResponse, + HabiticaGroupsResponse, HabiticaLoginResponse, HabiticaQuestResponse, HabiticaResponse, @@ -155,6 +156,9 @@ async def mock_habiticalib(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: client.create_task.return_value = HabiticaTaskResponse.from_json( await async_load_fixture(hass, "task.json", DOMAIN) ) + client.get_group.return_value = HabiticaGroupsResponse.from_json( + await async_load_fixture(hass, "party.json", DOMAIN) + ) yield client diff --git a/tests/components/habitica/fixtures/party.json b/tests/components/habitica/fixtures/party.json new file mode 100644 index 00000000000..18e7936ca85 --- /dev/null +++ b/tests/components/habitica/fixtures/party.json @@ -0,0 +1,75 @@ +{ + "success": true, + "data": { + "leaderOnly": { + "challenges": false, + "getGems": false + }, + "quest": { + "progress": { + "collect": { + "soapBars": 10 + } + }, + "key": "atom1", + "active": true, + "leader": "d69833ef-4542-4259-ba50-9b4a1a841bcf", + "members": { + "d69833ef-4542-4259-ba50-9b4a1a841bcf": true + }, + "extra": {} + }, + "tasksOrder": { + "habits": [], + "dailys": [], + "todos": [], + "rewards": [] + }, + "purchased": { + "plan": { + "consecutive": { + "count": 0, + "offset": 0, + "gemCapExtra": 0, + "trinkets": 0 + }, + "quantity": 1, + "extraMonths": 0, + "gemsBought": 0, + "cumulativeCount": 0, + "mysteryItems": [] + } + }, + "cron": {}, + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409", + "name": "test-user's Party", + "type": "party", + "privacy": "private", + "chat": [], + "memberCount": 2, + "challengeCount": 0, + "balance": 0, + "managers": {}, + "categories": [], + "leader": { + "auth": { + "local": { + "username": "test-username" + } + }, + "flags": { + "verifiedUsername": true + }, + "profile": { + "name": "test-user" + }, + "_id": "af36e2a8-7927-4dec-a258-400ade7f0ae3", + "id": "af36e2a8-7927-4dec-a258-400ade7f0ae3" + }, + "summary": "test-user's Party", + "id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" + }, + "notifications": [], + "userV": 0, + "appVersion": "5.38.0" +} diff --git a/tests/components/habitica/fixtures/party_2.json b/tests/components/habitica/fixtures/party_2.json new file mode 100644 index 00000000000..2c0ff528e32 --- /dev/null +++ b/tests/components/habitica/fixtures/party_2.json @@ -0,0 +1,74 @@ +{ + "success": true, + "data": { + "leaderOnly": { + "challenges": false, + "getGems": false + }, + "quest": { + "progress": { + "collect": {}, + "hp": 100 + }, + "key": "dustbunnies", + "active": true, + "leader": "d69833ef-4542-4259-ba50-9b4a1a841bcf", + "members": { + "d69833ef-4542-4259-ba50-9b4a1a841bcf": true + }, + "extra": {} + }, + "tasksOrder": { + "habits": [], + "dailys": [], + "todos": [], + "rewards": [] + }, + "purchased": { + "plan": { + "consecutive": { + "count": 0, + "offset": 0, + "gemCapExtra": 0, + "trinkets": 0 + }, + "quantity": 1, + "extraMonths": 0, + "gemsBought": 0, + "cumulativeCount": 0, + "mysteryItems": [] + } + }, + "cron": {}, + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409", + "name": "test-user's Party", + "type": "party", + "privacy": "private", + "chat": [], + "memberCount": 2, + "challengeCount": 0, + "balance": 0, + "managers": {}, + "categories": [], + "leader": { + "auth": { + "local": { + "username": "test-username" + } + }, + "flags": { + "verifiedUsername": true + }, + "profile": { + "name": "test-user" + }, + "_id": "af36e2a8-7927-4dec-a258-400ade7f0ae3", + "id": "af36e2a8-7927-4dec-a258-400ade7f0ae3" + }, + "summary": "test-user's Party", + "id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" + }, + "notifications": [], + "userV": 0, + "appVersion": "5.38.0" +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index d2f0091b6dd..28faec64dc9 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -76,7 +76,7 @@ "RSVPNeeded": true, "key": "dustbunnies" }, - "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" }, "tags": [ { diff --git a/tests/components/habitica/fixtures/user_no_party.json b/tests/components/habitica/fixtures/user_no_party.json index 1c58dde6f50..bd447b1af67 100644 --- a/tests/components/habitica/fixtures/user_no_party.json +++ b/tests/components/habitica/fixtures/user_no_party.json @@ -55,7 +55,9 @@ "e97659e0-2c42-4599-a7bb-00282adc410d", "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", - "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "369afeed-61e3-4bf7-9747-66e05807134c" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index 247063f2ae8..64dbc160a1b 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -48,3 +48,52 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.test_user_s_party_quest_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_s_party_quest_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Quest status', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_quest_running', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_s_party_quest_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': "test-user's Party Quest status", + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_s_party_quest_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 06f9ff9a6cd..89d6936f111 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -381,214 +381,6 @@ 'state': '137.625872146098', }) # --- -# name: test_sensors[sensor.test_user_habits-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_user_habits', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Habits', - 'platform': 'habitica', - 'previous_unique_id': None, - 'suggested_object_id': 'test_user_habits', - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_habits', - 'unit_of_measurement': 'tasks', - }) -# --- -# name: test_sensors[sensor.test_user_habits-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - '1d147de6-5c02-4740-8e2f-71d3015a37f4': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.266000+00:00', - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Eine kurze Pause machen', - 'type': 'habit', - 'up': True, - }), - 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.265000+00:00', - 'down': True, - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', - 'type': 'habit', - }), - 'e97659e0-2c42-4599-a7bb-00282adc410d': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.264000+00:00', - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Füge eine Aufgabe zu Habitica hinzu', - 'type': 'habit', - 'up': True, - }), - 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.268000+00:00', - 'down': True, - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Gesundes Essen/Junkfood', - 'type': 'habit', - 'up': True, - }), - 'friendly_name': 'test-user Habits', - 'unit_of_measurement': 'tasks', - }), - 'context': , - 'entity_id': 'sensor.test_user_habits', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- # name: test_sensors[sensor.test_user_hatching_potions-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -853,55 +645,6 @@ 'state': '50.9', }) # --- -# name: test_sensors[sensor.test_user_max_health-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_user_max_health', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Max. health', - 'platform': 'habitica', - 'previous_unique_id': None, - 'suggested_object_id': 'test_user_max_health', - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health_max', - 'unit_of_measurement': 'HP', - }) -# --- -# name: test_sensors[sensor.test_user_max_health-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'test-user Max. health', - 'unit_of_measurement': 'HP', - }), - 'context': , - 'entity_id': 'sensor.test_user_max_health', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '50', - }) -# --- # name: test_sensors[sensor.test_user_max_mana-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1321,7 +1064,7 @@ 'state': '2', }) # --- -# name: test_sensors[sensor.test_user_rewards-entry] +# name: test_sensors[sensor.test_user_s_party_boss_health-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1334,7 +1077,113 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_user_rewards', + 'entity_id': 'sensor.test_user_s_party_boss_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boss health', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_hp', + 'unit_of_measurement': 'HP', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': "test-user's Party Boss health", + 'unit_of_measurement': 'HP', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_s_party_boss_health_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boss health remaining', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss_hp_remaining', + 'unit_of_measurement': 'HP', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_boss_health_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', + 'friendly_name': "test-user's Party Boss health remaining", + 'unit_of_measurement': 'HP', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_boss_health_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_collected_quest_items-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_s_party_collected_quest_items', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1346,71 +1195,227 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Rewards', + 'original_name': 'Collected quest items', 'platform': 'habitica', 'previous_unique_id': None, - 'suggested_object_id': 'test_user_rewards', + 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_rewards', - 'unit_of_measurement': 'tasks', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_collected_items', + 'unit_of_measurement': 'items', }) # --- -# name: test_sensors[sensor.test_user_rewards-state] +# name: test_sensors[sensor.test_user_s_party_collected_quest_items-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.266000+00:00', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'tags': list([ - '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', - 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', - ]), - 'text': 'Belohne Dich selbst', - 'type': 'reward', - 'value': 10.0, - }), - 'friendly_name': 'test-user Rewards', - 'unit_of_measurement': 'tasks', + 'Seifenstücke': '10 / 20', + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_atom1_soapBars.png', + 'friendly_name': "test-user's Party Collected quest items", + 'unit_of_measurement': 'items', }), 'context': , - 'entity_id': 'sensor.test_user_rewards', + 'entity_id': 'sensor.test_user_s_party_collected_quest_items', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '10', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_group_leader-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_s_party_group_leader', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group leader', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_group_leader', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_group_leader-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "test-user's Party Group leader", + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_group_leader', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'test-user', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_member_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_s_party_member_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Member count', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_member_count', + 'unit_of_measurement': 'members', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_member_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii02IC02IDQ2IDUwIj48dGl0bGU+QnJvbnplX1NtYWxsPC90aXRsZT48cGF0aCBkPSJNMjAsMzYuMjhDNy4xOCwzMC42MSw1LjYsMjQuNjksNS41LDEwLjQyTDIwLDMuNzVsMTQuNSw2LjY3QzM0LjQsMjQuNjksMzIuODIsMzAuNjEsMjAsMzYuMjhaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMyAtMSkiIGZpbGw9IiNlYThjMzEiPjwvcGF0aD48cGF0aCBkPSJNMjAsNi41TDMyLDEyYy0wLjE1LDExLjU2LTEuNTEsMTYuNjItMTIsMjEuNTFDOS41MywyOC42NCw4LjE3LDIzLjU4LDgsMTJMMjAsNi41TTIwLDFMMyw4LjgyQzMsMjQuNDcsNC4xMywzMi4yOSwyMCwzOSwzNS44NywzMi4yOSwzNywyNC40NywzNyw4LjgyTDIwLDFoMFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0zIC0xKSIgZmlsbD0iI2IzNjIxMyI+PC9wYXRoPjxwYXRoIGQ9Ik0yMCw0LjNsMTQsNi40NGMtMC4xMiwxMy43Mi0xLjcyLDE5LjUxLTE0LDI1QzcuNzMsMzAuMjUsNi4xMiwyNC40Niw2LDEwLjc0TDIwLDQuM00yMCwxTDMsOC44MkMzLDI0LjQ3LDQuMTMsMzIuMjksMjAsMzksMzUuODcsMzIuMjksMzcsMjQuNDcsMzcsOC44MkwyMCwxaDBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMyAtMSkiIGZpbGw9IiNkNzdhMjAiPjwvcGF0aD48L3N2Zz4=', + 'friendly_name': "test-user's Party Member count", + 'unit_of_measurement': 'members', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_member_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_s_party_quest', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quest', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_quest', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_atom1.png', + 'friendly_name': "test-user's Party Quest", + 'quest_details': 'Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...', + 'quest_participants': '1 / 2', + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_quest', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!', + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest_boss-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_s_party_quest_boss', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quest boss', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_1e87097c-4c03-4f8c-a475-67cc7da7f409_boss', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_user_s_party_quest_boss-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "test-user's Party Quest boss", + }), + 'context': , + 'entity_id': 'sensor.test_user_s_party_quest_boss', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_sensors[sensor.test_user_saddles-entry] diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 5ec998ec82e..63001157695 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -16,7 +16,6 @@ from homeassistant.components.habitica.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, - CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, @@ -96,7 +95,6 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -151,7 +149,6 @@ async def test_form_login_errors( CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -219,7 +216,6 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -275,7 +271,6 @@ async def test_form_advanced_errors( CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py index 42a87d21a8a..b0810d8e76f 100644 --- a/tests/components/habitica/test_image.py +++ b/tests/components/habitica/test_image.py @@ -8,12 +8,13 @@ import sys from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from habiticalib import HabiticaUserResponse +from habiticalib import HabiticaGroupsResponse, HabiticaUserResponse import pytest +import respx from syrupy.assertion import SnapshotAssertion from syrupy.extensions.image import PNGImageSnapshotExtension -from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -97,3 +98,85 @@ async def test_image_platform( assert (await resp.read()) == snapshot( extension_class=PNGImageSnapshotExtension ) + + +@pytest.mark.usefixtures("habitica") +@respx.mock +async def test_load_image_from_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test loading of image from URL.""" + freezer.move_to("2024-09-20T22:00:00.000") + + call1 = respx.get(f"{ASSETS_URL}quest_atom1.png").respond(content=b"\x89PNG") + call2 = respx.get(f"{ASSETS_URL}quest_dustbunnies.png").respond(content=b"\x89PNG") + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:00:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == b"\x89PNG" + + assert call1.call_count == 1 + + habitica.get_group.return_value = HabiticaGroupsResponse.from_json( + await async_load_fixture(hass, "party_2.json", DOMAIN) + ) + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("image.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:15:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == b"\x89PNG" + assert call2.call_count == 1 + + +@pytest.mark.usefixtures("habitica") +@respx.mock +async def test_load_image_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test NotFound error.""" + freezer.move_to("2024-09-20T22:00:00.000") + + call1 = respx.get(f"{ASSETS_URL}quest_atom1.png").respond(status_code=404) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:00:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + assert call1.call_count == 1 diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index e904ccc890d..92be6cbe881 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse import pytest from homeassistant.components.habitica.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import ( ERROR_BAD_REQUEST, @@ -19,7 +21,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture @pytest.mark.usefixtures("habitica") @@ -128,3 +130,41 @@ async def test_coordinator_rate_limited( await hass.async_block_till_done() assert "Rate limit exceeded, will try again later" in caplog.text + + +async def test_remove_party_and_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we leave the party and device is removed.""" + group_id = "1e87097c-4c03-4f8c-a475-67cc7da7f409" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{config_entry.unique_id}_{group_id}")} + ) + is not None + ) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + await async_load_fixture(hass, "user_no_party.json", DOMAIN) + ) + + freezer.tick(datetime.timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{config_entry.unique_id}_{group_id}")} + ) + is None + ) diff --git a/tests/components/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index 1c648e38720..9dde266d214 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -6,13 +6,10 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import DOMAIN -from homeassistant.components.habitica.sensor import HabiticaSensorEntity -from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform @@ -36,19 +33,6 @@ async def test_sensors( ) -> None: """Test setup of the Habitica sensor platform.""" - for entity in ( - ("test_user_habits", "habits"), - ("test_user_rewards", "rewards"), - ("test_user_max_health", "health_max"), - ): - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{entity[1]}", - suggested_object_id=entity[0], - disabled_by=None, - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -56,96 +40,3 @@ async def test_sensors( assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) - - -@pytest.mark.parametrize( - ("entity_id", "key"), - [ - ("test_user_habits", HabiticaSensorEntity.HABITS), - ("test_user_rewards", HabiticaSensorEntity.REWARDS), - ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), - ], -) -@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") -async def test_sensor_deprecation_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, - entity_registry: er.EntityRegistry, - entity_id: str, - key: HabiticaSensorEntity, -) -> None: - """Test sensor deprecation issue.""" - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", - suggested_object_id=entity_id, - disabled_by=None, - ) - - assert entity_registry is not None - with patch( - "homeassistant.components.habitica.sensor.entity_used_in", return_value=True - ): - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - assert entity_registry.async_get(f"sensor.{entity_id}") is not None - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=f"deprecated_entity_{key}", - ) - - -@pytest.mark.parametrize( - ("entity_id", "key"), - [ - ("test_user_habits", HabiticaSensorEntity.HABITS), - ("test_user_rewards", HabiticaSensorEntity.REWARDS), - ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), - ], -) -@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") -async def test_sensor_deprecation_delete_disabled( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, - entity_registry: er.EntityRegistry, - entity_id: str, - key: HabiticaSensorEntity, -) -> None: - """Test sensor deletion .""" - - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", - suggested_object_id=entity_id, - disabled_by=er.RegistryEntryDisabler.USER, - ) - - assert entity_registry is not None - with patch( - "homeassistant.components.habitica.sensor.entity_used_in", return_value=True - ): - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - assert ( - issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=f"deprecated_entity_{key}", - ) - is None - ) - - assert entity_registry.async_get(f"sensor.{entity_id}") is None diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index ed1a6e312d3..3bc397b46f9 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -49,7 +49,6 @@ from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON @@ -326,7 +325,6 @@ async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -466,7 +464,6 @@ async def test_agent_info( client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value = mounts - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await client.send_json_auto_id({"type": "backup/agents/info"}) @@ -1474,7 +1471,6 @@ async def test_reader_writer_create_per_agent_encryption( ) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) for command in commands: @@ -2610,7 +2606,6 @@ async def test_restore_progress_after_restart( supervisor_client.jobs.get_job.return_value = get_job_result - async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2634,7 +2629,6 @@ async def test_restore_progress_after_restart_report_progress( supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE - async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2717,7 +2711,6 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError - async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2817,7 +2810,6 @@ async def test_config_load_config_info( hass_storage.update(storage_data) - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py index 4df8d2e81ac..4cdea02b087 100644 --- a/tests/components/hassio/test_config.py +++ b/tests/components/hassio/test_config.py @@ -1,13 +1,16 @@ """Test websocket API.""" +from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch -from uuid import UUID +from uuid import UUID, uuid4 import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.models import User +from homeassistant.components.hassio import HASSIO_USER_NAME from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -98,7 +101,24 @@ def mock_all( ) -@pytest.mark.usefixtures("hassio_env") +@pytest.fixture +def mock_hassio_user_id() -> Generator[None]: + """Mock the HASSIO user ID for snapshot testing.""" + original_user_init = User.__init__ + + def mock_user_init(self, *args, **kwargs): + with patch("homeassistant.auth.models.uuid.uuid4") as mock_uuid: + if kwargs.get("name") == HASSIO_USER_NAME: + mock_uuid.return_value = UUID(bytes=b"very_very_random", version=4) + else: + mock_uuid.return_value = uuid4() + original_user_init(self, *args, **kwargs) + + with patch.object(User, "__init__", mock_user_init): + yield + + +@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id") @pytest.mark.parametrize( "storage_data", [ @@ -151,10 +171,7 @@ async def test_load_config_store( await hass.auth.async_create_refresh_token(user) await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) - with ( - patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), - patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), - ): + with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -162,7 +179,7 @@ async def test_load_config_store( assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot -@pytest.mark.usefixtures("hassio_env") +@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id") async def test_save_config_store( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -171,10 +188,7 @@ async def test_save_config_store( snapshot: SnapshotAssertion, ) -> None: """Test saving the config store.""" - with ( - patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), - patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), - ): + with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 069abaa8513..cad410e6a21 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -4,6 +4,7 @@ from http import HTTPStatus from unittest.mock import MagicMock, patch from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO +from multidict import CIMultiDict import pytest from homeassistant.components.hassio.const import X_AUTH_TOKEN @@ -28,15 +29,22 @@ async def test_ingress_request_get( aioclient_mock.get( f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}", text="test", + headers=CIMultiDict( + [("Set-Cookie", "cookie1=value1"), ("Set-Cookie", "cookie2=value2")] + ), ) resp = await hassio_noauth_client.get( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", - headers={"X-Test-Header": "beer"}, + headers=CIMultiDict( + [("X-Test-Header", "beer"), ("X-Test-Header", "more beer")] + ), ) # Check we got right response assert resp.status == HTTPStatus.OK + assert resp.headers["Set-Cookie"] == "cookie1=value1" + assert resp.headers.getall("Set-Cookie") == ["cookie1=value1", "cookie2=value2"] body = await resp.text() assert body == "test" @@ -49,6 +57,10 @@ async def test_ingress_request_get( == f"/api/hassio_ingress/{build_type[0]}" ) assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer" + assert aioclient_mock.mock_calls[-1][3].getall("X-Test-Header") == [ + "beer", + "more beer", + ] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 2874ea726dc..f96ab8aca2a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1098,7 +1098,9 @@ def test_deprecated_function_is_hassio( ( "homeassistant.components.hassio", logging.WARNING, - "is_hassio is a deprecated function which will be removed in HA Core 2025.11. Use homeassistant.helpers.hassio.is_hassio instead", + "The deprecated function is_hassio was called. It will be " + "removed in HA Core 2025.11. Use homeassistant.helpers" + ".hassio.is_hassio instead", ) ] @@ -1114,7 +1116,9 @@ def test_deprecated_function_get_supervisor_ip( ( "homeassistant.helpers.hassio", logging.WARNING, - "get_supervisor_ip is a deprecated function which will be removed in HA Core 2025.11. Use homeassistant.helpers.hassio.get_supervisor_ip instead", + "The deprecated function get_supervisor_ip was called. It will " + "be removed in HA Core 2025.11. Use homeassistant.helpers" + ".hassio.get_supervisor_ip instead", ) ] diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index b0d3920be09..a4ad0a4a004 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -850,3 +850,50 @@ async def test_supervisor_issues_detached_addon_missing( "addon_url": "/hassio/addon/test", }, ) + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_disk_lifetime( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issue for disk lifetime nearly exceeded.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "disk_lifetime", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid=issue_uuid, + context="system", + type_="disk_lifetime", + fixable=False, + placeholders=None, + ) diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 4c4f0e24dcc..4234aab40c1 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -180,6 +180,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( ["system_execute_reboot", "system_execute_reboot"], ["system_test_type", "system_test_type"], ], + "required": False, "name": "next_step_id", } ], @@ -275,6 +276,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir ["system_execute_reboot", "system_execute_reboot"], ["system_test_type", "system_test_type"], ], + "required": False, "name": "next_step_id", } ], @@ -471,7 +473,6 @@ async def test_mount_failed_repair_flow_error( "flow_id": flow_id, "handler": "hassio", "reason": "apply_suggestion_fail", - "result": None, "description_placeholders": None, } @@ -546,6 +547,7 @@ async def test_mount_failed_repair_flow( ["mount_execute_reload", "mount_execute_reload"], ["mount_execute_remove", "mount_execute_remove"], ], + "required": False, "name": "next_step_id", } ], @@ -757,6 +759,7 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( ["system_rename_data_disk", "system_rename_data_disk"], ["system_adopt_data_disk", "system_adopt_data_disk"], ], + "required": False, "name": "next_step_id", } ], @@ -961,6 +964,7 @@ async def test_supervisor_issue_addon_boot_fail( ["addon_execute_start", "addon_execute_start"], ["addon_disable_boot", "addon_disable_boot"], ], + "required": False, "name": "next_step_id", } ], diff --git a/tests/components/hassio/test_system_health.py b/tests/components/hassio/test_system_health.py index c4c2b861e6e..4839486810a 100644 --- a/tests/components/hassio/test_system_health.py +++ b/tests/components/hassio/test_system_health.py @@ -55,6 +55,10 @@ async def test_hassio_system_health( hass.data["hassio_network_info"] = { "host_internet": True, "supervisor_internet": True, + "interfaces": [ + {"primary": False, "ipv4": {"nameservers": ["9.9.9.9"]}}, + {"primary": True, "ipv4": {"nameservers": ["1.1.1.1"]}}, + ], } with patch.dict(os.environ, MOCK_ENVIRON): @@ -76,6 +80,7 @@ async def test_hassio_system_health( "host_os": "Home Assistant OS 5.9", "installed_addons": "Awesome Addon (1.0.0)", "ntp_synchronized": True, + "nameservers": "1.1.1.1", "supervisor_api": "ok", "supervisor_version": "supervisor-2020.11.1", "supported": True, diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 6ecc2b44244..cfc3a923399 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -26,7 +26,6 @@ from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -246,7 +245,6 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non async def setup_backup_integration(hass: HomeAssistant) -> None: """Set up the backup integration.""" - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 8c68e9bf705..1f2a7d34819 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -27,7 +27,6 @@ from homeassistant.components.hassio.const import ( ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component @@ -360,7 +359,6 @@ async def test_update_addon( async def setup_backup_integration(hass: HomeAssistant) -> None: """Set up the backup integration.""" - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index 62882c7df8b..56ad9fdcb0e 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -1,15 +1,12 @@ """The tests for the hddtemp platform.""" import socket -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest -from homeassistant.components.hddtemp import DOMAIN -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import UnitOfTemperature -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component VALID_CONFIG_MINIMAL = {"sensor": {"platform": "hddtemp"}} @@ -195,17 +192,3 @@ async def test_hddtemp_host_unreachable(hass: HomeAssistant, telnetmock) -> None assert await async_setup_component(hass, "sensor", VALID_CONFIG_HOST_UNREACHABLE) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - - -@patch.dict("sys.modules", gsp=Mock()) -async def test_repair_issue_is_created( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue is created.""" - assert await async_setup_component(hass, PLATFORM_DOMAIN, VALID_CONFIG_MINIMAL) - await hass.async_block_till_done() - assert ( - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - ) in issue_registry.issues diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 835e4436398..e72c72c7334 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from ipaddress import ip_address from unittest.mock import Mock, patch from pyheos import ( @@ -39,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, SsdpServiceInfo, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -284,6 +286,36 @@ def discovery_data_fixture_bedroom() -> SsdpServiceInfo: ) +@pytest.fixture(name="zeroconf_discovery_data") +def zeroconf_discovery_data_fixture() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.1" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenon._heos-audio._tcp.local.", + properties={}, + ) + + +@pytest.fixture(name="zeroconf_discovery_data_bedroom") +def zeroconf_discovery_data_fixture_bedroom() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.2" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenonBedroom._heos-audio._tcp.local.", + properties={}, + ) + + @pytest.fixture(name="quick_selects") def quick_selects_fixture() -> dict[int, str]: """Create a dict of quick selects for testing.""" diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69d9aa3a38e..4749dc48b01 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -18,12 +18,14 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_SSDP, SOURCE_USER, + SOURCE_ZEROCONF, ConfigEntryState, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -244,6 +246,143 @@ async def test_discovery_updates( assert config_entry.data[CONF_HOST] == "127.0.0.2" +async def test_zeroconf_discovery( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test discovery shows form to confirm, then creates entry.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + assert controller.connect.call_count == 1 + assert controller.get_system_info.call_count == 1 + assert controller.disconnect.call_count == 1 + + # Subsequent discovered hosts abort. + subsequent_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert subsequent_result["type"] is FlowResultType.ABORT + assert subsequent_result["reason"] == "already_in_progress" + + # Confirm set up + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + + +async def test_zeroconf_discovery_flow_aborts_already_setup( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test discovery flow aborts when entry already setup and hosts didn't change.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 0 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_aborts_same_system( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, + system: HeosSystem, +) -> None: + """Test discovery does not update when current host is part of discovered's system.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 1 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_ignored_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_zeroconf_discovery_fails_to_connect_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + controller: MockHeos, +) -> None: + """Test discovery aborts when trying to connect to host.""" + controller.connect.side_effect = HeosError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + + +async def test_zeroconf_discovery_updates( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, +) -> None: + """Test discovery updates existing entry.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + host = HeosHost("Player", "Model", None, None, "127.0.0.2", NetworkType.WIRED, True) + controller.get_system_info.return_value = HeosSystem(None, host, [host]) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_reconfigure_validates_and_updates_config( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: diff --git a/tests/components/here_travel_time/test_config_flow.py b/tests/components/here_travel_time/test_config_flow.py index ce210813fb2..82c75471896 100644 --- a/tests/components/here_travel_time/test_config_flow.py +++ b/tests/components/here_travel_time/test_config_flow.py @@ -6,7 +6,10 @@ from here_routing import HERERoutingError, HERERoutingUnauthorizedError import pytest from homeassistant import config_entries -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, @@ -17,6 +20,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DOMAIN, ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, @@ -86,6 +90,8 @@ async def option_init_result_fixture( CONF_MODE: TRAVEL_MODE_PUBLIC, CONF_NAME: "test", }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -249,6 +255,7 @@ async def test_step_destination_entity( CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: None, CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, } @@ -317,6 +324,8 @@ async def do_common_reconfiguration_steps(hass: HomeAssistant) -> None: unique_id="0123456789", data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) @@ -398,6 +407,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: domain=DOMAIN, unique_id="0123456789", data=DEFAULT_CONFIG, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) @@ -414,10 +425,16 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: False, }, ) - assert result["type"] is FlowResultType.MENU + assert result["type"] is FlowResultType.CREATE_ENTRY + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.options == { + CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: False, + } @pytest.mark.usefixtures("valid_response") @@ -441,6 +458,7 @@ async def test_options_flow_arrival_time_step( assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: "08:00:00", + CONF_TRAFFIC_MODE: True, } @@ -465,6 +483,7 @@ async def test_options_flow_departure_time_step( assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_DEPARTURE_TIME: "08:00:00", + CONF_TRAFFIC_MODE: True, } @@ -481,4 +500,5 @@ async def test_options_flow_no_time_step( entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: True, } diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index ff09c7e6ae9..4dbddd46633 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -4,14 +4,19 @@ from datetime import datetime import pytest -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DOMAIN, ROUTE_MODE_FASTEST, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import DEFAULT_CONFIG @@ -44,9 +49,34 @@ async def test_unload_entry(hass: HomeAssistant, options) -> None: unique_id="0123456789", data=DEFAULT_CONFIG, options=options, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("valid_response") +async def test_migrate_entry_v1_1_v1_2( + hass: HomeAssistant, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + version=1, + minor_version=1, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.minor_version == 2 + assert updated_entry.options[CONF_TRAFFIC_MODE] is True diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 7c8946b7049..b96e77a6b6d 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -11,6 +11,7 @@ from here_routing import ( Return, RoutingMode, Spans, + TrafficMode, TransportMode, ) from here_transit import ( @@ -21,7 +22,10 @@ from here_transit import ( ) import pytest -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, @@ -32,6 +36,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_SCAN_INTERVAL, DOMAIN, ICON_BICYCLE, @@ -85,29 +90,33 @@ from tests.common import ( @pytest.mark.parametrize( - ("mode", "icon", "arrival_time", "departure_time"), + ("mode", "icon", "traffic_mode", "arrival_time", "departure_time"), [ ( TRAVEL_MODE_CAR, ICON_CAR, + False, None, None, ), ( TRAVEL_MODE_BICYCLE, ICON_BICYCLE, + True, None, None, ), ( TRAVEL_MODE_PEDESTRIAN, ICON_PEDESTRIAN, + True, None, "08:00:00", ), ( TRAVEL_MODE_TRUCK, ICON_TRUCK, + True, None, "08:00:00", ), @@ -118,6 +127,7 @@ async def test_sensor( hass: HomeAssistant, mode, icon, + traffic_mode, arrival_time, departure_time, ) -> None: @@ -137,9 +147,12 @@ async def test_sensor( }, options={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: traffic_mode, CONF_ARRIVAL_TIME: arrival_time, CONF_DEPARTURE_TIME: departure_time, }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -197,6 +210,8 @@ async def test_circular_ref( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -228,7 +243,10 @@ async def test_public_transport(hass: HomeAssistant) -> None: CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: "08:00:00", CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -260,6 +278,8 @@ async def test_no_attribution_response(hass: HomeAssistant) -> None: CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -307,6 +327,8 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -324,6 +346,7 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non routing_mode=RoutingMode.FAST, arrival_time=None, departure_time=None, + traffic_mode=TrafficMode.DEFAULT, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -346,6 +369,8 @@ async def test_destination_entity_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -374,6 +399,8 @@ async def test_origin_entity_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -406,6 +433,8 @@ async def test_invalid_destination_entity_state( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -440,6 +469,8 @@ async def test_invalid_origin_entity_state( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -476,6 +507,8 @@ async def test_route_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -587,7 +620,12 @@ async def test_restore_state(hass: HomeAssistant) -> None: # create and add entry mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id=DOMAIN, data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS + domain=DOMAIN, + unique_id=DOMAIN, + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) mock_entry.add_to_hass(hass) @@ -656,6 +694,8 @@ async def test_transit_errors( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -682,6 +722,8 @@ async def test_routing_rate_limit( unique_id="0123456789", data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -739,6 +781,8 @@ async def test_transit_rate_limit( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -791,6 +835,8 @@ async def test_multiple_sections( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/history_stats/test_config_flow.py b/tests/components/history_stats/test_config_flow.py index a695a06995e..08dbefe7465 100644 --- a/tests/components/history_stats/test_config_flow.py +++ b/tests/components/history_stats/test_config_flow.py @@ -2,22 +2,28 @@ from __future__ import annotations -from unittest.mock import AsyncMock +import logging +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time from homeassistant import config_entries from homeassistant.components.history_stats.const import ( CONF_DURATION, CONF_END, CONF_START, + CONF_TYPE_COUNT, DEFAULT_NAME, DOMAIN, ) from homeassistant.components.recorder import Recorder from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.data_entry_flow import FlowResultType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_form( @@ -193,3 +199,351 @@ async def test_entry_already_exist( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_config_flow_preview_success( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + # add state for the tests + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + t1 = start_time.replace(hour=3) + t2 = start_time.replace(hour=4) + t3 = start_time.replace(hour=5) + + monitored_entity = "binary_sensor.state" + + def _fake_states(*args, **kwargs): + return { + monitored_entity: [ + State( + monitored_entity, + "on", + last_changed=start_time, + last_updated=start_time, + ), + State( + monitored_entity, + "off", + last_changed=t1, + last_updated=t1, + ), + State( + monitored_entity, + "on", + last_changed=t2, + last_updated=t2, + ), + ] + } + + 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" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "options" + assert result["errors"] is None + assert result["preview"] == "history_stats" + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(t3), + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{now()}}", + CONF_START: "{{ today_at() }}", + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == "2" + + +async def test_options_flow_preview( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the options flow preview.""" + logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR) + client = await hass_ws_client(hass) + + # add state for the tests + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + t1 = start_time.replace(hour=3) + t2 = start_time.replace(hour=4) + t3 = start_time.replace(hour=5) + + monitored_entity = "binary_sensor.state" + + def _fake_states(*args, **kwargs): + return { + monitored_entity: [ + State( + monitored_entity, + "on", + last_changed=start_time, + last_updated=start_time, + ), + State( + monitored_entity, + "off", + last_changed=t1, + last_updated=t1, + ), + State( + monitored_entity, + "on", + last_changed=t2, + last_updated=t2, + ), + State( + monitored_entity, + "off", + last_changed=t2, + last_updated=t2, + ), + ] + } + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + }, + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "history_stats" + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(t3), + ): + for end, exp_count in ( + ("{{now()}}", "2"), + ("{{today_at('2:00')}}", "1"), + ("{{today_at('23:00')}}", "2"), + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: end, + CONF_START: "{{ today_at() }}", + }, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == exp_count + + hass.states.async_set(monitored_entity, "on") + + msg = await client.receive_json() + assert msg["event"]["state"] == "3" + + +async def test_options_flow_preview_errors( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the options flow preview.""" + logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR) + client = await hass_ws_client(hass) + + # add state for the tests + monitored_entity = "binary_sensor.state" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + }, + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "history_stats" + + for schema in ( + {CONF_END: "{{ now() }"}, # Missing '}' at end of template + {CONF_START: "{{ today_at( }}"}, # Missing ')' in template function + {CONF_DURATION: {"hours": 1}}, # Specified 3 period keys (1 too many) + {CONF_START: ""}, # Specified 1 period keys (1 too few) + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + **schema, + }, + } + ) + + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "invalid_schema" + + for schema in ( + {CONF_END: "{{ nowwww() }}"}, # Unknown jinja function + {CONF_START: "{{ today_at('abcde') }}"}, # Invalid value passed to today_at + {CONF_END: '"{{ now() }}"'}, # Invalid quotes around template + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + **schema, + }, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["error"] + + +async def test_options_flow_sensor_preview_config_entry_removed( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_START: "0", + CONF_END: "1", + }, + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "history_stats" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_START: "0", + CONF_END: "1", + }, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "home_assistant_error", + "message": "Config entry not found", + } diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index cb3350f497f..7f81fe6625f 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -18,7 +18,7 @@ from homeassistant.components.history_stats.const import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -92,6 +92,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -181,7 +182,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(history_stats_config_entry.entry_id) @@ -196,9 +197,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures("recorder_mock") @@ -210,6 +209,56 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the history_stats config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the history_stats config entry is removed + assert ( + history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the history_stats config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -226,7 +275,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -243,7 +292,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -273,7 +325,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -288,7 +340,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the entity is no longer linked to the source device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id is None + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -322,7 +378,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries @@ -339,11 +395,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is moved to the other device + # Check that the entity is linked to the other device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert history_stats_config_entry.entry_id in sensor_device_2.config_entries + assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -369,7 +429,7 @@ async def test_async_handle_source_entity_new_entity_id( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -387,12 +447,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the history_stats config entry is updated with the new entity ID assert history_stats_config_entry.options[CONF_ENTITY_ID] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes history_stats config entry from device.""" + + history_stats_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: sensor_entity_entry.entity_id, + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=1, + minor_version=1, + ) + history_stats_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + assert history_stats_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + assert history_stats_config_entry.version == 1 + assert history_stats_config_entry.minor_version == 2 + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test", + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index e18489d5220..a57743dfc9e 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -12,11 +12,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'CookProcessor', 'vib': 'HCS000006', @@ -32,11 +42,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'DNE', 'vib': 'HCS000000', @@ -52,11 +72,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Hob', 'vib': 'HCS000005', @@ -74,11 +104,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'WasherDryer', 'vib': 'HCS000001', @@ -94,11 +134,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Refrigerator', 'vib': 'HCS000002', @@ -114,11 +164,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Freezer', 'vib': 'HCS000003', @@ -135,21 +195,57 @@ 'Cooking.Common.Program.Hood.DelayedShutOff', ]), 'settings': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': 70, - 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', - 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', - 'BSH.Common.Setting.AmbientLightEnabled': True, - 'Cooking.Common.Setting.Lighting': True, - 'Cooking.Common.Setting.LightingBrightness': 70, - 'Cooking.Hood.Setting.ColorTemperature': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'value': True, + }), + 'Cooking.Common.Setting.Lighting': dict({ + 'value': True, + }), + 'Cooking.Common.Setting.LightingBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'Cooking.Hood.Setting.ColorTemperature': dict({ + 'constraints': dict({ + 'allowed_values': list([ + 'Cooking.Hood.EnumType.ColorTemperature.warm', + 'Cooking.Hood.EnumType.ColorTemperature.neutral', + 'Cooking.Hood.EnumType.ColorTemperature.cold', + ]), + }), + 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', + }), + 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ + 'unit': '%', + 'value': 70, + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Hood', 'vib': 'HCS000004', @@ -166,15 +262,29 @@ 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), 'settings': dict({ - 'BSH.Common.Setting.AlarmClock': 0, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.Setting.AlarmClock': dict({ + 'value': 0, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'value': 'BSH.Common.EnumType.PowerState.On', + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Oven', 'vib': 'HCS01OVN1', @@ -193,11 +303,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Dryer', 'vib': 'HCS04DYR1', @@ -219,11 +339,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'CoffeeMaker', 'vib': 'HCS06COM1', @@ -242,19 +372,48 @@ 'Dishcare.Dishwasher.Program.Quick45', ]), 'settings': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': 70, - 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', - 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', - 'BSH.Common.Setting.AmbientLightEnabled': True, - 'BSH.Common.Setting.ChildLock': False, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'value': True, + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'constraints': dict({ + 'allowed_values': list([ + 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.EnumType.PowerState.Off', + ]), + }), + 'value': 'BSH.Common.EnumType.PowerState.On', + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Dishwasher', 'vib': 'HCS02DWH1', @@ -273,16 +432,32 @@ 'LaundryCare.Washer.Program.Wool', ]), 'settings': dict({ - 'BSH.Common.Setting.ChildLock': False, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', - 'LaundryCare.Washer.Setting.IDos2BaseLevel': 0, + 'BSH.Common.Setting.ChildLock': dict({ + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'LaundryCare.Washer.Setting.IDos2BaseLevel': dict({ + 'value': 0, + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Washer', 'vib': 'HCS03WCH1', @@ -296,19 +471,57 @@ 'programs': list([ ]), 'settings': dict({ - 'Refrigeration.Common.Setting.Dispenser.Enabled': False, - 'Refrigeration.Common.Setting.Light.External.Brightness': 70, - 'Refrigeration.Common.Setting.Light.External.Power': True, - 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, + 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'value': False, + }), + 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ + 'constraints': dict({ + 'access': 'readWrite', + 'max': 100, + 'min': 0, + }), + 'unit': '%', + 'value': 70, + }), + 'Refrigeration.Common.Setting.Light.External.Power': dict({ + 'value': True, + }), + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': dict({ + 'unit': '°C', + 'value': 8, + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'value': False, + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'value': False, + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'FridgeFreezer', 'vib': 'HCS05FRF1', @@ -330,19 +543,48 @@ 'Dishcare.Dishwasher.Program.Quick45', ]), 'settings': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': 70, - 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', - 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', - 'BSH.Common.Setting.AmbientLightEnabled': True, - 'BSH.Common.Setting.ChildLock': False, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'value': True, + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'constraints': dict({ + 'allowed_values': list([ + 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.EnumType.PowerState.Off', + ]), + }), + 'value': 'BSH.Common.EnumType.PowerState.On', + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Dishwasher', 'vib': 'HCS02DWH1', diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index ee4d5f1d729..e61ec5e2b1f 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -1,12 +1,14 @@ """Tests for home_connect button entities.""" from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfCommands, CommandKey, + Event, + EventKey, EventMessage, HomeAppliance, ) @@ -317,3 +319,62 @@ async def test_stop_program_button_exception( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_enable_resume_command_on_pause( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test if all commands enabled option works as expected.""" + entity_id = "button.washer_resume_program" + + original_get_available_commands = client.get_available_commands + + async def get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + array_of_commands = cast( + ArrayOfCommands, await original_get_available_commands(ha_id) + ) + if ha_id == appliance.ha_id: + for command in array_of_commands.commands: + if command.key == CommandKey.BSH_COMMON_RESUME_PROGRAM: + # Simulate that the resume command is not available initially + array_of_commands.commands.remove(command) + break + return array_of_commands + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE.value, + timestamp=0, + level="", + handling="", + value="BSH.Common.EnumType.OperationState.Pause", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index ad35f890528..d6fe70144c0 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -29,32 +29,67 @@ DHCP_DISCOVERY = ( DhcpServiceInfo( ip="1.1.1.1", hostname="balay-dishwasher-000000000000000000", - macaddress="C8:D7:78:00:00:00", + macaddress="c8d778000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="BOSCH-ABCDE1234-68A40E000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="BOSCH-ABCDE1234-68A40E000000", + macaddress="38b4d3000000", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="bosch-dishwasher-000000000000000000", + macaddress="68a40e000000", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="bosch-dishwasher-000000000000000000", + macaddress="38b4d3000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="SIEMENS-ABCDE1234-68A40E000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="SIEMENS-ABCDE1234-38B4D3000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="siemens-dishwasher-000000000000000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="siemens-dishwasher-000000000000000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="NEFF-ABCDE1234-68A40E000000", + macaddress="68a40e000000", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="NEFF-ABCDE1234-38B4D3000000", + macaddress="38b4d3000000", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="neff-dishwasher-000000000000000000", + macaddress="68a40e000000", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="neff-dishwasher-000000000000000000", + macaddress="38b4d3000000", ), ) @@ -431,7 +466,7 @@ async def test_dhcp_flow_already_setup( DhcpServiceInfo( ip="1.1.1.1", hostname="bosch-cookprocessor-123456789012345678", - macaddress="c8:d7:78:00:00:00", + macaddress="c8d778000000", ), "CookProcessor", ), @@ -439,7 +474,7 @@ async def test_dhcp_flow_already_setup( DhcpServiceInfo( ip="1.1.1.1", hostname="BOSCH-HCS000000-68A40E000000", - macaddress="68:a4:0e:00:00:00", + macaddress="68a40e000000", ), "Hob", ), @@ -472,5 +507,5 @@ async def test_dhcp_flow_complete_device_information( device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device assert device.connections == { - (dr.CONNECTION_NETWORK_MAC, dhcp_discovery.macaddress) + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(dhcp_discovery.macaddress)) } diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index f9fed995b89..a368cfbef2d 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -53,16 +52,11 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -580,8 +574,7 @@ async def test_paired_disconnected_devices_not_fetching( async def test_coordinator_disabling_updates_for_appliance( hass: HomeAssistant, - hass_client: ClientSessionGenerator, - issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -592,7 +585,6 @@ async def test_coordinator_disabling_updates_for_appliance( When the user confirms the issue the updates should be enabled again. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -606,13 +598,26 @@ async def test_coordinator_disabling_updates_for_appliance( EventType.CONNECTED, data=ArrayOfEvents([]), ) - for _ in range(8) + for _ in range(6) ] ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue + freezer.tick(timedelta(minutes=10)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(2) + ] + ) + await hass.async_block_till_done() + + # At this point, the updates have been blocked because + # 6 + 2 connected events have been received in less than an hour get_settings_original_side_effect = client.get_settings.side_effect @@ -644,18 +649,36 @@ async def test_coordinator_disabling_updates_for_appliance( assert hass.states.is_state("switch.dishwasher_power", STATE_ON) - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, + # After 55 minutes, the updates should be enabled again + # because one hour has passed since the first connect events, + # so there are 2 connected events in the execution_tracker + freezer.tick(timedelta(minutes=55)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - assert resp.status == HTTPStatus.OK + await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + # If more connect events are sent, it should be blocked again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) # 2 + 1 + 5 = 8 connect events in less than an hour + ] + ) + await hass.async_block_till_done() + client.get_settings = get_settings_original_side_effect await client.add_events( [ EventMessage( @@ -672,7 +695,6 @@ async def test_coordinator_disabling_updates_for_appliance( async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( hass: HomeAssistant, - issue_registry: ir.IssueRegistry, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -682,7 +704,6 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r The repair issue should also be deleted. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -701,14 +722,9 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 9a4f41d08e1..dc9fb1d34c2 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -1,6 +1,6 @@ """The tests for the time automation.""" -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory @@ -877,3 +877,200 @@ async def test_if_at_template_limited_template( await hass.async_block_till_done() assert "is not supported in limited templates" in caplog.text + + +async def test_if_fires_using_weekday_single( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], +) -> None: + """Test for firing on a specific weekday.""" + # Freeze time to Monday, January 2, 2023 at 5:00:00 + monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0)) + + freezer.move_to(monday_trigger) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00", "weekday": "mon"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.strftime('%A') }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + # Fire the trigger on Monday + async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == "time - Monday" + + # Fire on Tuesday at the same time - should not trigger + tuesday_trigger = dt_util.as_utc(datetime(2023, 1, 3, 5, 0, 0, 0)) + async_fire_time_changed(hass, tuesday_trigger) + await hass.async_block_till_done() + + # Should still be only 1 call + assert len(service_calls) == 1 + + +async def test_if_fires_using_weekday_multiple( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], +) -> None: + """Test for firing on multiple weekdays.""" + # Freeze time to Monday, January 2, 2023 at 5:00:00 + monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0)) + + freezer.move_to(monday_trigger) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": "5:00:00", + "weekday": ["mon", "wed", "fri"], + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.strftime('%A') }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + # Fire on Monday - should trigger + async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1)) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert "Monday" in service_calls[0].data["some"] + + # Fire on Tuesday - should not trigger + tuesday_trigger = dt_util.as_utc(datetime(2023, 1, 3, 5, 0, 0, 0)) + async_fire_time_changed(hass, tuesday_trigger) + await hass.async_block_till_done() + assert len(service_calls) == 1 + + # Fire on Wednesday - should trigger + wednesday_trigger = dt_util.as_utc(datetime(2023, 1, 4, 5, 0, 0, 0)) + async_fire_time_changed(hass, wednesday_trigger) + await hass.async_block_till_done() + assert len(service_calls) == 2 + assert "Wednesday" in service_calls[1].data["some"] + + # Fire on Friday - should trigger + friday_trigger = dt_util.as_utc(datetime(2023, 1, 6, 5, 0, 0, 0)) + async_fire_time_changed(hass, friday_trigger) + await hass.async_block_till_done() + assert len(service_calls) == 3 + assert "Friday" in service_calls[2].data["some"] + + +async def test_if_fires_using_weekday_with_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], +) -> None: + """Test for firing on weekday with input_datetime entity.""" + await async_setup_component( + hass, + "input_datetime", + {"input_datetime": {"trigger": {"has_date": False, "has_time": True}}}, + ) + + # Freeze time to Monday, January 2, 2023 at 5:00:00 + monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0)) + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "time": "05:00:00", + }, + blocking=True, + ) + + freezer.move_to(monday_trigger) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": "input_datetime.trigger", + "weekday": "mon", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.strftime('%A') }}", + "entity": "{{ trigger.entity_id }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + # Fire on Monday - should trigger + async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1)) + await hass.async_block_till_done() + automation_calls = [call for call in service_calls if call.domain == "test"] + assert len(automation_calls) == 1 + assert "Monday" in automation_calls[0].data["some"] + assert automation_calls[0].data["entity"] == "input_datetime.trigger" + + # Fire on Tuesday - should not trigger + tuesday_trigger = dt_util.as_utc(datetime(2023, 1, 3, 5, 0, 0, 0)) + async_fire_time_changed(hass, tuesday_trigger) + await hass.async_block_till_done() + automation_calls = [call for call in service_calls if call.domain == "test"] + assert len(automation_calls) == 1 + + +def test_weekday_validation() -> None: + """Test weekday validation in trigger schema.""" + # Valid single weekday + valid_config = {"platform": "time", "at": "5:00:00", "weekday": "mon"} + time.TRIGGER_SCHEMA(valid_config) + + # Valid multiple weekdays + valid_config = { + "platform": "time", + "at": "5:00:00", + "weekday": ["mon", "wed", "fri"], + } + time.TRIGGER_SCHEMA(valid_config) + + # Invalid weekday + invalid_config = {"platform": "time", "at": "5:00:00", "weekday": "invalid"} + with pytest.raises(vol.Invalid): + time.TRIGGER_SCHEMA(invalid_config) + + # Invalid weekday in list + invalid_config = { + "platform": "time", + "at": "5:00:00", + "weekday": ["mon", "invalid"], + } + with pytest.raises(vol.Invalid): + time.TRIGGER_SCHEMA(invalid_config) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 2d5067bea3e..d5039f3b0bd 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -4,9 +4,16 @@ import asyncio from collections.abc import Awaitable, Callable, Generator, Iterator import contextlib from typing import Any -from unittest.mock import AsyncMock, Mock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from aiohttp import ClientError +from ha_silabs_firmware_client import ( + FirmwareManifest, + FirmwareMetadata, + FirmwareUpdateClient, +) import pytest +from yarl import URL from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( @@ -19,12 +26,13 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, get_otbr_addon_manager, - get_zigbee_flasher_addon_manager, ) from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from tests.common import ( MockConfigEntry, @@ -37,6 +45,7 @@ from tests.common import ( TEST_DOMAIN = "test_firmware_domain" TEST_DEVICE = "/dev/SomeDevice123" TEST_HARDWARE_NAME = "Some Hardware Name" +TEST_RELEASES_URL = URL("http://invalid/releases") class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): @@ -62,6 +71,32 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): return await self.async_step_confirm() + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self._install_firmware_step( + fw_update_url=TEST_RELEASES_URL, + fw_type="fake_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="pre_confirm_zigbee", + ) + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self._install_firmware_step( + fw_update_url=TEST_RELEASES_URL, + fw_type="fake_openthread_rcp", + firmware_name="Thread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="start_otbr_addon", + ) + def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" assert self._device is not None @@ -99,6 +134,18 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): # Regenerate the translation placeholders self._get_translation_placeholders() + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self.async_step_pre_confirm_zigbee() + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self.async_step_start_otbr_addon() + def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" assert self._probed_firmware_info is not None @@ -146,12 +193,23 @@ def delayed_side_effect() -> Callable[..., Awaitable[None]]: return side_effect +def create_mock_owner() -> Mock: + """Mock for OwningAddon / OwningIntegration.""" + owner = Mock() + owner.is_running = AsyncMock(return_value=True) + owner.temporarily_stop = MagicMock() + owner.temporarily_stop.return_value.__aenter__.return_value = AsyncMock() + + return owner + + @contextlib.contextmanager -def mock_addon_info( +def mock_firmware_info( hass: HomeAssistant, *, is_hassio: bool = True, - app_type: ApplicationType | None = ApplicationType.EZSP, + probe_app_type: ApplicationType | None = ApplicationType.EZSP, + probe_fw_version: str | None = "2.4.4.0", otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -160,29 +218,10 @@ def mock_addon_info( update_available=False, version=None, ), - flasher_addon_info: AddonInfo = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ), + flash_app_type: ApplicationType = ApplicationType.EZSP, + flash_fw_version: str | None = "7.4.4.0", ) -> Iterator[tuple[Mock, Mock]]: """Mock the main addon states for the config flow.""" - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_get_addon_info.return_value = flasher_addon_info - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) mock_otbr_manager.addon_name = "OpenThread Border Router" mock_otbr_manager.async_install_addon_waiting = AsyncMock( @@ -196,17 +235,87 @@ def mock_addon_info( ) mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info - if app_type is None: - firmware_info_result = None + mock_update_client = AsyncMock(spec_set=FirmwareUpdateClient) + mock_update_client.async_update_data.return_value = FirmwareManifest( + url=TEST_RELEASES_URL, + html_url=TEST_RELEASES_URL / "html", + created_at=utcnow(), + firmwares=[ + FirmwareMetadata( + filename="fake_openthread_rcp_7.4.4.0_variant.gbl", + checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + size=123, + release_notes="Some release notes", + metadata={ + "baudrate": 460800, + "fw_type": "openthread_rcp", + "fw_variant": None, + "metadata_version": 2, + "ot_rcp_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "sdk_version": "4.4.4", + }, + url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl", + ), + FirmwareMetadata( + filename="fake_zigbee_ncp_7.4.4.0_variant.gbl", + checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + size=123, + release_notes="Some release notes", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", + ), + ], + ) + + if probe_app_type is None: + probed_firmware_info = None else: - firmware_info_result = FirmwareInfo( + probed_firmware_info = FirmwareInfo( device="/dev/ttyUSB0", # Not used - firmware_type=app_type, - firmware_version=None, + firmware_type=probe_app_type, + firmware_version=probe_fw_version, owners=[], source="probe", ) + if flash_app_type is None: + flashed_firmware_info = None + else: + flashed_firmware_info = FirmwareInfo( + device=TEST_DEVICE, + firmware_type=flash_app_type, + firmware_version=flash_fw_version, + owners=[create_mock_owner()], + source="probe", + ) + + async def mock_flash_firmware( + hass: HomeAssistant, + device: str, + fw_data: bytes, + expected_installed_firmware_type: ApplicationType, + bootloader_reset_type: str | None = None, + progress_callback: Callable[[int, int], None] | None = None, + ) -> FirmwareInfo: + await asyncio.sleep(0) + progress_callback(0, 100) + await asyncio.sleep(0) + progress_callback(50, 100) + await asyncio.sleep(0) + progress_callback(100, 100) + + if flashed_firmware_info is None: + raise HomeAssistantError("Failed to probe the firmware after flashing") + + return flashed_firmware_info + with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", @@ -216,10 +325,6 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", return_value=mock_otbr_manager, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", return_value=is_hassio, @@ -229,81 +334,85 @@ def mock_addon_info( return_value=is_hassio, ), patch( + # We probe once before installation and once after "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=firmware_info_result, + side_effect=(probed_firmware_info, flashed_firmware_info), + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient", + return_value=mock_update_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware", + side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager, mock_flasher_manager + yield mock_otbr_manager, mock_update_client + + +async def consume_progress_flow( + hass: HomeAssistant, + flow_id: str, + valid_step_ids: tuple[str], +) -> ConfigFlowResult: + """Consume a progress flow until it is done.""" + while True: + result = await hass.config_entries.flow.async_configure(flow_id) + flow_id = result["flow_id"] + + if result["type"] != FlowResultType.SHOW_PROGRESS: + break + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] in valid_step_ids + + await asyncio.sleep(0.1) + + return result async def test_config_flow_zigbee(hass: HomeAssistant) -> None: """Test the config flow.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - # Pick the menu option: we are now installing the addon - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + probe_app_type=ApplicationType.SPINEL, + flash_app_type=ApplicationType.EZSP, + ): + # Pick the menu option: we are flashing the firmware + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_zigbee_flasher_addon" - assert result["description_placeholders"]["firmware_type"] == "spinel" - await hass.async_block_till_done(wait_background_tasks=True) + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_zigbee_firmware" - # Progress the flow, we are now configuring the addon and running it - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "run_zigbee_flasher_addon" - assert result["progress_action"] == "run_zigbee_flasher_addon" - assert mock_flasher_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - ) - ] - - await hass.async_block_till_done(wait_background_tasks=True) - - # Progress the flow, we are now uninstalling the addon - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "uninstall_zigbee_flasher_addon" - assert result["progress_action"] == "uninstall_zigbee_flasher_addon" - - await hass.async_block_till_done(wait_background_tasks=True) - - # We are finally done with the addon - assert mock_flasher_manager.async_uninstall_addon_waiting.mock_calls == [call()] - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - with mock_addon_info( - hass, - app_type=ApplicationType.EZSP, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + confirm_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=("install_zigbee_firmware",), ) - assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = result["result"] + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = create_result["result"] assert config_entry.data == { "firmware": "ezsp", "device": TEST_DEVICE, @@ -319,6 +428,91 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +async def test_config_flow_firmware_index_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if index download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_firmware_info( + hass, + # The correct firmware is already installed + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client): + # Mock the firmware download to fail + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_firmware_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if firmware download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The correct firmware is already installed so installation isn't required + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_doesnt_downgrade( + hass: HomeAssistant, +) -> None: + """Test flow exits early, without downgrading firmware.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + probe_app_type=ApplicationType.EZSP, + # An newer version is probed than what we offer + probe_fw_version="7.5.0.0", + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware" + ) as mock_async_flash_silabs_firmware, + ): + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + assert len(mock_async_flash_silabs_firmware.mock_calls) == 0 + + async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None: """Test the config flow, skip installing the addon if necessary.""" result = await hass.config_entries.flow.async_init( @@ -328,52 +522,20 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - flasher_addon_info=AddonInfo( - available=True, - hostname=None, - options={ - "device": "", - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - }, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.2.3", - ), - ) as (mock_otbr_manager, mock_flasher_manager): + with mock_firmware_info(hass, probe_app_type=ApplicationType.SPINEL): # Pick the menu option: we skip installation, instead we directly run it result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "run_zigbee_flasher_addon" - assert result["progress_action"] == "run_zigbee_flasher_addon" - assert result["description_placeholders"]["firmware_type"] == "spinel" - assert mock_flasher_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - ) - ] - - # Uninstall the addon - await hass.async_block_till_done(wait_background_tasks=True) + # Confirm result = await hass.config_entries.flow.async_configure(result["flow_id"]) # Done - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, ): await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -409,28 +571,29 @@ async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: async def test_config_flow_thread(hass: HomeAssistant) -> None: """Test the config flow.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, _): # Pick the menu option - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_otbr_addon" - assert result["description_placeholders"]["firmware_type"] == "ezsp" - assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS + assert pick_result["progress_action"] == "install_addon" + assert pick_result["step_id"] == "install_otbr_addon" + assert pick_result["description_placeholders"]["firmware_type"] == "ezsp" + assert pick_result["description_placeholders"]["model"] == TEST_HARDWARE_NAME await hass.async_block_till_done(wait_background_tasks=True) @@ -441,19 +604,37 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: "device": "", "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, }, state=AddonState.NOT_RUNNING, update_available=False, version="1.2.3", ) - # Progress the flow, it is now configuring the addon and running it - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Progress the flow, it is now installing firmware + confirm_otbr_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_otbr_addon", + "install_thread_firmware", + "start_otbr_addon", + ), + ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_otbr_addon" - assert result["progress_action"] == "start_otbr_addon" + # Installation will conclude with the config entry being created + create_result = await hass.config_entries.flow.async_configure( + confirm_otbr_result["flow_id"], user_input={} + ) + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } assert mock_otbr_manager.async_set_addon_options.mock_calls == [ call( @@ -461,44 +642,22 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: "device": TEST_DEVICE, "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, } ) ] - await hass.async_block_till_done(wait_background_tasks=True) - - # The addon is now running - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - config_entry = result["result"] - assert config_entry.data == { - "firmware": "spinel", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } - async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None: """Test the Thread config flow, addon is already installed.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, otbr_addon_info=AddonInfo( available=True, hostname=None, @@ -507,81 +666,50 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - update_available=False, version=None, ), - ) as (mock_otbr_manager, mock_flasher_manager): + ) as (mock_otbr_manager, _): # Pick the menu option - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_otbr_addon" - assert result["progress_action"] == "start_otbr_addon" + # Progress + confirm_otbr_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), + ) + + # We're now waiting to confirm OTBR + assert confirm_otbr_result["type"] is FlowResultType.FORM + assert confirm_otbr_result["step_id"] == "confirm_otbr" + + # The addon has been installed assert mock_otbr_manager.async_set_addon_options.mock_calls == [ call( { "device": TEST_DEVICE, "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, # And firmware flashing is disabled } ) ] - await hass.async_block_till_done(wait_background_tasks=True) - - # The addon is now running - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + # Finally, create the config entry + create_result = await hass.config_entries.flow.async_configure( + confirm_otbr_result["flow_id"], user_input={} ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - -async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: - """Test when the stick is used with a non-hassio setup.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - is_hassio=False, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - config_entry = result["result"] - assert config_entry.data == { - "firmware": "ezsp", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } - - # Ensure a ZHA discovery flow has been created - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - zha_flow = flows[0] - assert zha_flow["handler"] == "zha" - assert zha_flow["context"]["source"] == "hardware" - assert zha_flow["step_id"] == "confirm" + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"].data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } @pytest.mark.usefixtures("addon_store_info") @@ -601,10 +729,11 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, _): # First step is confirmation result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU @@ -630,7 +759,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: "device": "", "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, }, state=AddonState.NOT_RUNNING, update_available=False, @@ -650,7 +779,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: "device": TEST_DEVICE, "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, } ) ] @@ -662,10 +791,6 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ): # We are now done result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={} @@ -700,57 +825,23 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: assert result["description_placeholders"]["firmware_type"] == "spinel" assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.SPINEL, + ): # Pick the menu option: we are now installing the addon result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_zigbee_flasher_addon" - - await hass.async_block_till_done(wait_background_tasks=True) - - # Progress the flow, we are now configuring the addon and running it - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "run_zigbee_flasher_addon" - assert result["progress_action"] == "run_zigbee_flasher_addon" - assert mock_flasher_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - ) - ] - - await hass.async_block_till_done(wait_background_tasks=True) - - # Progress the flow, we are now uninstalling the addon - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "uninstall_zigbee_flasher_addon" - assert result["progress_action"] == "uninstall_zigbee_flasher_addon" - - await hass.async_block_till_done(wait_background_tasks=True) - - # We are finally done with the addon - assert mock_flasher_manager.async_uninstall_addon_waiting.mock_calls == [call()] result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, ): # We are now done result = await hass.config_entries.options.async_configure( diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 38c2696a62a..0494de1432c 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from homeassistant.components.hassio import AddonError, AddonInfo, AddonState @@ -21,8 +22,8 @@ from .test_config_flow import ( TEST_DEVICE, TEST_DOMAIN, TEST_HARDWARE_NAME, - delayed_side_effect, - mock_addon_info, + consume_progress_flow, + mock_firmware_info, mock_test_firmware_platform, # noqa: F401 ) @@ -51,10 +52,10 @@ async def test_config_flow_cannot_probe_firmware( ) -> None: """Test failure case when firmware cannot be probed.""" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=None, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=None, + ): # Start the flow result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} @@ -69,283 +70,6 @@ async def test_config_flow_cannot_probe_firmware( assert result["reason"] == "unsupported_firmware" -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_not_hassio_wrong_firmware( - hass: HomeAssistant, -) -> None: - """Test when the stick is used with a non-hassio setup but the firmware is bad.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - is_hassio=False, - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_hassio" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_already_running( - hass: HomeAssistant, -) -> None: - """Test failure case when flasher addon is already running.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - flasher_addon_info=AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - # Cannot get addon info - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_already_running" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon cannot be installed.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - flasher_addon_info=AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_get_addon_info.side_effect = AddonError() - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - # Cannot get addon info - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_info_failed" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_install_fails( - hass: HomeAssistant, -) -> None: - """Test failure case when flasher addon cannot be installed.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - # Cannot install addon - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_install_failed" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_set_config_fails( - hass: HomeAssistant, -) -> None: - """Test failure case when flasher addon cannot be configured.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_set_addon_options = AsyncMock( - side_effect=AddonError() - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_set_config_failed" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_run_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon fails to run.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_start_failed" - - -async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon uninstall fails.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - # Uninstall failure isn't critical - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None: - """Test the config flow failing due to Zigbee firmware not being detected.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - - with mock_addon_info( - hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - # Pick the menu option: we are now installing the addon - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - with mock_addon_info( - hass, - app_type=None, # Probing fails - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], @@ -356,11 +80,11 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, is_hassio=False, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -383,10 +107,10 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, _): mock_otbr_manager.async_get_addon_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -405,24 +129,26 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> None: +async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) -> None: """Test failure case when the Thread addon is already running.""" result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, otbr_addon_info=AddonInfo( available=True, hostname=None, - options={}, + options={ + "device": TEST_DEVICE + "2", # A different device + }, state=AddonState.RUNNING, update_available=False, version="1.0.0", ), - ) as (mock_otbr_manager, mock_flasher_manager): + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -450,10 +176,10 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -477,29 +203,51 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No ) async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be configured.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, _): + + async def install_addon() -> None: + mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname=None, + options={"device": TEST_DEVICE}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + + mock_otbr_manager.async_install_addon_waiting = AsyncMock( + side_effect=install_addon + ) mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + + pick_thread_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_set_config_failed" + pick_thread_progress_result = await consume_progress_flow( + hass, + flow_id=pick_thread_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), + ) + + assert pick_thread_progress_result["type"] == FlowResultType.ABORT + assert pick_thread_progress_result["reason"] == "addon_set_config_failed" @pytest.mark.parametrize( @@ -508,63 +256,45 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> ) async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + otbr_addon_info=AddonInfo( + available=True, + hostname=None, + options={"device": TEST_DEVICE}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ), + ) as (mock_otbr_manager, _): mock_otbr_manager.async_start_addon_waiting = AsyncMock( side_effect=AddonError() ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_thread_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_start_failed" - - -async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon uninstall fails.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=AddonError() + pick_thread_progress_result = await consume_progress_flow( + hass, + flow_id=pick_thread_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - # Uninstall failure isn't critical - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" + assert pick_thread_progress_result["type"] == FlowResultType.ABORT + assert pick_thread_progress_result["reason"] == "addon_start_failed" @pytest.mark.parametrize( @@ -573,40 +303,101 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) - ) async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to OpenThread firmware not being detected.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + probe_app_type=ApplicationType.EZSP, + flash_app_type=None, + otbr_addon_info=AddonInfo( + available=True, + hostname=None, + options={"device": TEST_DEVICE}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ), + ): + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_thread_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - - with mock_addon_info( - hass, - app_type=None, # Probing fails - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + pick_thread_progress_result = await consume_progress_flow( + hass, + flow_id=pick_thread_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" + + assert pick_thread_progress_result["type"] is FlowResultType.ABORT + assert pick_thread_progress_result["reason"] == "fw_install_failed" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_index_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if OTA index download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if firmware download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" @pytest.mark.parametrize( @@ -683,9 +474,9 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( # Confirm options flow result = await hass.config_entries.options.async_init(config_entry.entry_id) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.SPINEL, + probe_app_type=ApplicationType.SPINEL, otbr_addon_info=AddonInfo( available=True, hostname=None, @@ -694,7 +485,7 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( update_available=False, version="1.0.0", ), - ) as (mock_otbr_manager, mock_flasher_manager): + ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, diff --git a/tests/components/homeassistant_hardware/test_coordinator.py b/tests/components/homeassistant_hardware/test_coordinator.py index 9c57aac6811..39fef3366ad 100644 --- a/tests/components/homeassistant_hardware/test_coordinator.py +++ b/tests/components/homeassistant_hardware/test_coordinator.py @@ -13,6 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util +from tests.common import MockConfigEntry + async def test_firmware_update_coordinator_fetching( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -20,6 +22,8 @@ async def test_firmware_update_coordinator_fetching( """Test the firmware update coordinator loads manifests.""" session = async_get_clientsession(hass) + mock_config_entry = MockConfigEntry() + manifest = FirmwareManifest( url=URL("https://example.org/firmware"), html_url=URL("https://example.org/release_notes"), @@ -35,7 +39,7 @@ async def test_firmware_update_coordinator_fetching( return_value=mock_client, ): coordinator = FirmwareUpdateCoordinator( - hass, session, "https://example.org/firmware" + hass, mock_config_entry, session, "https://example.org/firmware" ) listener = Mock() diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 81c6f2e0459..3103e5cfc6a 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable import dataclasses import logging -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch import aiohttp from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata @@ -143,6 +143,7 @@ def _mock_async_create_update_entity( config_entry=config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + config_entry, session, TEST_FIRMWARE_RELEASES_URL, ), @@ -355,10 +356,14 @@ async def test_update_entity_installation( "https://example.org/release_notes" ) - mock_firmware = Mock() - mock_flasher = AsyncMock() - - async def mock_flash_firmware(fw_image, progress_callback): + async def mock_flash_firmware( + hass: HomeAssistant, + device: str, + fw_data: bytes, + expected_installed_firmware_type: ApplicationType, + bootloader_reset_type: str | None = None, + progress_callback: Callable[[int, int], None] | None = None, + ) -> FirmwareInfo: await asyncio.sleep(0) progress_callback(0, 100) await asyncio.sleep(0) @@ -366,31 +371,20 @@ async def test_update_entity_installation( await asyncio.sleep(0) progress_callback(100, 100) - mock_flasher.flash_firmware = mock_flash_firmware + return FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ) # When we install it, the other integration is reloaded with ( patch( - "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", - return_value=mock_firmware, + "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware", + side_effect=mock_flash_firmware, ), - patch( - "homeassistant.components.homeassistant_hardware.update.Flasher", - return_value=mock_flasher, - ), - patch( - "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=TEST_DEVICE, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ), - ), - patch.object( - owning_config_entry, "async_unload", wraps=owning_config_entry.async_unload - ) as owning_config_entry_unload, ): state_changes: list[Event[EventStateChangedData]] = async_capture_events( hass, EVENT_STATE_CHANGED @@ -423,9 +417,6 @@ async def test_update_entity_installation( assert state_changes[6].data["new_state"].attributes["update_percentage"] is None assert state_changes[6].data["new_state"].attributes["in_progress"] is False - # The owning integration was unloaded and is again running - assert len(owning_config_entry_unload.mock_calls) == 1 - # After the firmware update, the entity has the new version and the correct state state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) assert state_after_install is not None @@ -456,19 +447,10 @@ async def test_update_entity_installation_failure( assert state_before_install.attributes["installed_version"] == "7.3.1.0" assert state_before_install.attributes["latest_version"] == "7.4.4.0" - mock_flasher = AsyncMock() - mock_flasher.flash_firmware.side_effect = RuntimeError( - "Something broke during flashing!" - ) - with ( patch( - "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", - return_value=Mock(), - ), - patch( - "homeassistant.components.homeassistant_hardware.update.Flasher", - return_value=mock_flasher, + "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware", + side_effect=HomeAssistantError("Failed to flash firmware"), ), pytest.raises(HomeAssistantError, match="Failed to flash firmware"), ): @@ -511,16 +493,10 @@ async def test_update_entity_installation_probe_failure( with ( patch( - "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", - return_value=Mock(), - ), - patch( - "homeassistant.components.homeassistant_hardware.update.Flasher", - return_value=AsyncMock(), - ), - patch( - "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", - return_value=None, + "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware", + side_effect=HomeAssistantError( + "Failed to probe the firmware after flashing" + ), ), pytest.raises( HomeAssistantError, match="Failed to probe the firmware after flashing" @@ -618,6 +594,7 @@ async def test_update_entity_graceful_firmware_type_callback_errors( config_entry=update_config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + update_config_entry, session, TEST_FIRMWARE_RELEASES_URL, ), diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 1b7bfe4a8ac..048bf998d13 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -1,10 +1,13 @@ """Test hardware utilities.""" -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from collections.abc import Callable +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest from universal_silabs_flasher.common import Version as FlasherVersion from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType +from universal_silabs_flasher.firmware import GBLImage from homeassistant.components.hassio import ( AddonError, @@ -20,6 +23,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, OwningAddon, OwningIntegration, + async_flash_silabs_firmware, get_otbr_addon_firmware_info, guess_firmware_info, probe_silabs_firmware_info, @@ -27,8 +31,11 @@ from homeassistant.components.homeassistant_hardware.util import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from .test_config_flow import create_mock_owner + from tests.common import MockConfigEntry ZHA_CONFIG_ENTRY = MockConfigEntry( @@ -526,3 +533,201 @@ async def test_probe_silabs_firmware_type( ): result = await probe_silabs_firmware_type("/dev/ttyUSB0") assert result == expected + + +async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: + """Test async_flash_silabs_firmware.""" + owner1 = create_mock_owner() + owner2 = create_mock_owner() + + progress_callback = Mock() + + async def mock_flash_firmware( + fw_image: GBLImage, progress_callback: Callable[[int, int], None] + ) -> None: + """Mock flash firmware function.""" + await asyncio.sleep(0) + progress_callback(0, 100) + await asyncio.sleep(0) + progress_callback(50, 100) + await asyncio.sleep(0) + progress_callback(100, 100) + await asyncio.sleep(0) + + mock_flasher = Mock() + mock_flasher.enter_bootloader = AsyncMock() + mock_flasher.flash_firmware = AsyncMock(side_effect=mock_flash_firmware) + + expected_firmware_info = FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="probe", + owners=[], + ) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[owner1, owner2], + ), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + patch( + "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", + return_value=expected_firmware_info, + ), + ): + after_flash_info = await async_flash_silabs_firmware( + hass=hass, + device="/dev/ttyUSB0", + fw_data=b"firmware contents", + expected_installed_firmware_type=ApplicationType.SPINEL, + bootloader_reset_type=None, + progress_callback=progress_callback, + ) + + assert progress_callback.mock_calls == [call(0, 100), call(50, 100), call(100, 100)] + assert after_flash_info == expected_firmware_info + + # Both owning integrations/addons are stopped and restarted + assert owner1.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] + + assert owner2.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] + + +async def test_async_flash_silabs_firmware_flash_failure(hass: HomeAssistant) -> None: + """Test async_flash_silabs_firmware flash failure.""" + owner1 = create_mock_owner() + owner2 = create_mock_owner() + + mock_flasher = Mock() + mock_flasher.enter_bootloader = AsyncMock() + mock_flasher.flash_firmware = AsyncMock(side_effect=RuntimeError("Failure!")) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[owner1, owner2], + ), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + pytest.raises(HomeAssistantError, match="Failed to flash firmware") as exc, + ): + await async_flash_silabs_firmware( + hass=hass, + device="/dev/ttyUSB0", + fw_data=b"firmware contents", + expected_installed_firmware_type=ApplicationType.SPINEL, + bootloader_reset_type=None, + ) + + # Both owning integrations/addons are stopped and restarted + assert owner1.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), + ] + assert owner2.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), + ] + + +async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> None: + """Test async_flash_silabs_firmware probe failure.""" + owner1 = create_mock_owner() + owner2 = create_mock_owner() + + mock_flasher = Mock() + mock_flasher.enter_bootloader = AsyncMock() + mock_flasher.flash_firmware = AsyncMock() + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[owner1, owner2], + ), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + patch( + "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", + return_value=None, + ), + pytest.raises( + HomeAssistantError, match="Failed to probe the firmware after flashing" + ), + ): + await async_flash_silabs_firmware( + hass=hass, + device="/dev/ttyUSB0", + fw_data=b"firmware contents", + expected_installed_firmware_type=ApplicationType.SPINEL, + bootloader_reset_type=None, + ) + + # Both owning integrations/addons are stopped and restarted + assert owner1.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] + assert owner2.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 44a5e0029c3..bdde5e09ea6 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( @@ -18,6 +19,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo @@ -28,14 +30,31 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("usb_data", "model"), + ("step", "usb_data", "model", "fw_type", "fw_version"), [ - (USB_DATA_SKY, "Home Assistant SkyConnect"), - (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1"), + ( + STEP_PICK_FIRMWARE_ZIGBEE, + USB_DATA_SKY, + "Home Assistant SkyConnect", + ApplicationType.EZSP, + "7.4.4.0 build 0", + ), + ( + STEP_PICK_FIRMWARE_THREAD, + USB_DATA_ZBT1, + "Home Assistant Connect ZBT-1", + ApplicationType.SPINEL, + "2.4.4.0", + ), ], ) async def test_config_flow( - usb_data: UsbServiceInfo, model: str, hass: HomeAssistant + step: str, + usb_data: UsbServiceInfo, + model: str, + fw_type: ApplicationType, + fw_version: str, + hass: HomeAssistant, ) -> None: """Test the config flow for SkyConnect.""" result = await hass.config_entries.flow.async_init( @@ -46,37 +65,60 @@ async def test_config_flow( assert result["step_id"] == "pick_firmware" assert result["description_placeholders"]["model"] == model - async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + async def mock_install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: + if next_step_id == "start_otbr_addon": + next_step_id = "pre_confirm_otbr" + + return await getattr(self, f"async_step_{next_step_id}")(user_input={}) with ( patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow.async_step_pick_firmware_zigbee", + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + side_effect=mock_install_firmware_step, ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", return_value=FirmwareInfo( device=usb_data.device, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", + firmware_type=fw_type, + firmware_version=fw_version, owners=[], source="probe", ), ), ): - result = await hass.config_entries.flow.async_configure( + confirm_result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) - config_entry = result["result"] + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] assert config_entry.data == { - "firmware": "ezsp", - "firmware_version": "7.4.4.0 build 0", + "firmware": fw_type.value, + "firmware_version": fw_version, "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, @@ -86,13 +128,17 @@ async def test_config_flow( "vid": usb_data.vid, } - # Ensure a ZHA discovery flow has been created flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - zha_flow = flows[0] - assert zha_flow["handler"] == "zha" - assert zha_flow["context"]["source"] == "hardware" - assert zha_flow["step_id"] == "confirm" + + if step == STEP_PICK_FIRMWARE_ZIGBEE: + # Ensure a ZHA discovery flow has been created + assert len(flows) == 1 + zha_flow = flows[0] + assert zha_flow["handler"] == "zha" + assert zha_flow["context"]["source"] == "hardware" + assert zha_flow["step_id"] == "confirm" + else: + assert len(flows) == 0 @pytest.mark.parametrize( @@ -133,7 +179,7 @@ async def test_options_flow( assert result["description_placeholders"]["model"] == model async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() with ( patch( @@ -152,13 +198,19 @@ async def test_options_flow( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.data == { "firmware": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 1d5a64eafb9..6e2120aa961 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.hassio import ( AddonState, ) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( @@ -23,6 +24,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, ) from homeassistant.components.homeassistant_yellow.const import DOMAIN, RADIO_DEVICE +from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -305,7 +307,17 @@ async def test_option_flow_led_settings_fail_2( assert result["reason"] == "write_hw_settings_error" -async def test_firmware_options_flow(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("step", "fw_type", "fw_version"), + [ + (STEP_PICK_FIRMWARE_ZIGBEE, ApplicationType.EZSP, "7.4.4.0 build 0"), + (STEP_PICK_FIRMWARE_THREAD, ApplicationType.SPINEL, "2.4.4.0"), + ], +) +@pytest.mark.usefixtures("addon_store_info") +async def test_firmware_options_flow( + step: str, fw_type: ApplicationType, fw_version: str, hass: HomeAssistant +) -> None: """Test the firmware options flow for Yellow.""" mock_integration(hass, MockModule("hassio")) await async_setup_component(hass, HASSIO_DOMAIN, {}) @@ -337,7 +349,21 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: assert result["description_placeholders"]["model"] == "Home Assistant Yellow" async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() + + async def mock_install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: + if next_step_id == "start_otbr_addon": + next_step_id = "pre_confirm_otbr" + + return await getattr(self, f"async_step_{next_step_id}")(user_input={}) with ( patch( @@ -345,28 +371,45 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: autospec=True, side_effect=mock_async_step_pick_firmware_zigbee, ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._ensure_thread_addon_setup", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._install_firmware_step", + autospec=True, + side_effect=mock_install_firmware_step, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", return_value=FirmwareInfo( device=RADIO_DEVICE, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", + firmware_type=fw_type, + firmware_version=fw_version, owners=[], source="probe", ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.data == { - "firmware": "ezsp", - "firmware_version": "7.4.4.0 build 0", + "firmware": fw_type.value, + "firmware_version": fw_version, } diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index f9fa95c593f..3db3e809374 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -15,7 +15,9 @@ HOMEE_IP = "192.168.1.11" NEW_HOMEE_IP = "192.168.1.12" HOMEE_NAME = "TestHomee" TESTUSER = "testuser" +NEW_TESTUSER = "testuser2" TESTPASS = "testpass" +NEW_TESTPASS = "testpass2" @pytest.fixture diff --git a/tests/components/homee/fixtures/cover_with_position_slats.json b/tests/components/homee/fixtures/cover_with_position_slats.json index 8fd0d6f44fe..a61be87ab9f 100644 --- a/tests/components/homee/fixtures/cover_with_position_slats.json +++ b/tests/components/homee/fixtures/cover_with_position_slats.json @@ -96,6 +96,55 @@ "options": { "automations": ["step"] } + }, + { + "id": 4, + "node_id": 3, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 20.3, + "target_value": 20.3, + "last_value": 20.3, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + }, + { + "id": 5, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 0, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "text", + "step_value": 1.0, + "editable": 0, + "type": 44, + "state": 1, + "last_changed": 0, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "4.54", + "name": "" } ] } diff --git a/tests/components/homee/fixtures/cover_without_position.json b/tests/components/homee/fixtures/cover_without_position.json index e2bc6c7a38d..f6e9ea19c8a 100644 --- a/tests/components/homee/fixtures/cover_without_position.json +++ b/tests/components/homee/fixtures/cover_without_position.json @@ -43,6 +43,27 @@ "observes": [75], "automations": ["toggle"] } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 0, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "text", + "step_value": 1.0, + "editable": 0, + "type": 45, + "state": 1, + "last_changed": 0, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "1.45", + "name": "" } ] } diff --git a/tests/components/homee/snapshots/test_diagnostics.ambr b/tests/components/homee/snapshots/test_diagnostics.ambr index 76d3f426e17..d934c4e225e 100644 --- a/tests/components/homee/snapshots/test_diagnostics.ambr +++ b/tests/components/homee/snapshots/test_diagnostics.ambr @@ -689,6 +689,55 @@ 'type': 113, 'unit': '°', }), + dict({ + 'based_on': 1, + 'changed_by': 1, + 'changed_by_id': 0, + 'current_value': 20.3, + 'data': '', + 'editable': 0, + 'id': 4, + 'instance': 0, + 'last_changed': 1709982925, + 'last_value': 20.3, + 'maximum': 125, + 'minimum': -50, + 'name': '', + 'node_id': 3, + 'options': dict({ + 'history': dict({ + 'day': 1, + 'month': 6, + 'week': 26, + }), + }), + 'state': 1, + 'step_value': 1.0, + 'target_value': 20.3, + 'type': 5, + 'unit': '°C', + }), + dict({ + 'based_on': 1, + 'changed_by': 1, + 'changed_by_id': 0, + 'current_value': 0.0, + 'data': '4.54', + 'editable': 0, + 'id': 5, + 'instance': 0, + 'last_changed': 0, + 'last_value': 0.0, + 'maximum': 0, + 'minimum': 0, + 'name': '', + 'node_id': 3, + 'state': 1, + 'step_value': 1.0, + 'target_value': 0.0, + 'type': 44, + 'unit': 'text', + }), ]), 'cube_type': 14, 'favorite': 0, diff --git a/tests/components/homee/snapshots/test_init.ambr b/tests/components/homee/snapshots/test_init.ambr new file mode 100644 index 00000000000..8f20bb10454 --- /dev/null +++ b/tests/components/homee/snapshots/test_init.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_general_data + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '00:05:55:11:ee:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homee', + '00055511EECC', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'homee', + 'model': 'homee', + 'model_id': None, + 'name': 'TestHomee', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': '1.2.3', + 'via_device_id': None, + }) +# --- +# name: test_general_data.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homee', + '00055511EECC-3', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'shutter_position_switch', + 'model_id': None, + 'name': 'Test Cover', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': '4.54', + 'via_device_id': , + }) +# --- diff --git a/tests/components/homee/test_config_flow.py b/tests/components/homee/test_config_flow.py index 70d34ced91c..3d2195443a2 100644 --- a/tests/components/homee/test_config_flow.py +++ b/tests/components/homee/test_config_flow.py @@ -1,20 +1,55 @@ """Test the Homee config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock from pyHomee import HomeeAuthFailedException, HomeeConnectionFailedException import pytest -from homeassistant.components.homee.const import DOMAIN +from homeassistant import config_entries +from homeassistant.components.homee.const import ( + DOMAIN, + RESULT_CANNOT_CONNECT, + RESULT_INVALID_AUTH, + RESULT_UNKNOWN_ERROR, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .conftest import HOMEE_ID, HOMEE_IP, HOMEE_NAME, NEW_HOMEE_IP, TESTPASS, TESTUSER +from .conftest import ( + HOMEE_ID, + HOMEE_IP, + HOMEE_NAME, + NEW_HOMEE_IP, + NEW_TESTPASS, + NEW_TESTUSER, + TESTPASS, + TESTUSER, +) from tests.common import MockConfigEntry +PARAMETRIZED_ERRORS = ( + ("side_eff", "error"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + {"base": RESULT_CANNOT_CONNECT}, + ), + ( + HomeeAuthFailedException("wrong username or password"), + {"base": RESULT_INVALID_AUTH}, + ), + ( + Exception, + {"base": RESULT_UNKNOWN_ERROR}, + ), + ], +) + @pytest.mark.usefixtures("mock_homee", "mock_config_entry", "mock_setup_entry") async def test_config_flow( @@ -49,23 +84,7 @@ async def test_config_flow( assert result["result"].unique_id == HOMEE_ID -@pytest.mark.parametrize( - ("side_eff", "error"), - [ - ( - HomeeConnectionFailedException("connection timed out"), - {"base": "cannot_connect"}, - ), - ( - HomeeAuthFailedException("wrong username or password"), - {"base": "invalid_auth"}, - ), - ( - Exception, - {"base": "unknown"}, - ), - ], -) +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) async def test_config_flow_errors( hass: HomeAssistant, mock_homee: AsyncMock, @@ -113,7 +132,6 @@ async def test_flow_already_configured( ) -> None: """Test config flow aborts when already configured.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -132,6 +150,280 @@ async def test_flow_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_homee", "mock_config_entry") +async def test_zeroconf_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_homee: AsyncMock, +) -> None: + """Test zeroconf discovery flow.""" + mock_homee.get_access_token.side_effect = HomeeAuthFailedException( + "wrong username or password" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(HOMEE_IP), + ip_addresses=[ip_address(HOMEE_IP)], + port=22, + properties={}, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["handler"] == DOMAIN + mock_setup_entry.assert_not_called() + + mock_homee.get_access_token.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["data"] == { + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + } + + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) +async def test_zeroconf_confirm_errors( + hass: HomeAssistant, + mock_homee: AsyncMock, + side_eff: Exception, + error: dict[str, str], +) -> None: + """Test zeroconf discovery flow errors.""" + mock_homee.get_access_token.side_effect = HomeeAuthFailedException( + "wrong username or password" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(HOMEE_IP), + ip_addresses=[ip_address(HOMEE_IP)], + port=22, + properties={}, + ), + ) + + flow_id = result["flow_id"] + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["handler"] == DOMAIN + + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == error + + mock_homee.get_access_token.side_effect = None + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_zeroconf_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery flow when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(HOMEE_IP), + ip_addresses=[ip_address(HOMEE_IP)], + port=22, + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_eff", "ip", "reason"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + HOMEE_IP, + RESULT_CANNOT_CONNECT, + ), + (Exception, HOMEE_IP, RESULT_CANNOT_CONNECT), + (None, "2001:db8::1", "ipv6_address"), + ], +) +async def test_zeroconf_errors( + hass: HomeAssistant, + mock_homee: AsyncMock, + side_eff: Exception, + ip: str, + reason: str, +) -> None: + """Test zeroconf discovery flow with an IPv6 address.""" + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(ip), + ip_addresses=[ip_address(ip)], + port=22, + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_homee", "mock_setup_entry") +async def test_reauth_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauth flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["handler"] == DOMAIN + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + # Confirm that the config entry has been updated + assert mock_config_entry.data[CONF_HOST] == HOMEE_IP + assert mock_config_entry.data[CONF_USERNAME] == NEW_TESTUSER + assert mock_config_entry.data[CONF_PASSWORD] == NEW_TESTPASS + + +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) +async def test_reauth_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: AsyncMock, + side_eff: Exception, + error: dict[str, str], +) -> None: + """Test reconfigure flow errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == error + + # Confirm that the config entry is unchanged + assert mock_config_entry.data[CONF_USERNAME] == TESTUSER + assert mock_config_entry.data[CONF_PASSWORD] == TESTPASS + + mock_homee.get_access_token.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Confirm that the config entry has been updated + assert mock_config_entry.data[CONF_HOST] == HOMEE_IP + assert mock_config_entry.data[CONF_USERNAME] == NEW_TESTUSER + assert mock_config_entry.data[CONF_PASSWORD] == NEW_TESTPASS + + +async def test_reauth_wrong_uid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: AsyncMock, +) -> None: + """Test reauth flow with wrong UID.""" + mock_homee.settings.uid = "wrong_uid" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "wrong_hub" + + # Confirm that the config entry is unchanged + assert mock_config_entry.data[CONF_HOST] == HOMEE_IP + + @pytest.mark.usefixtures("mock_setup_entry") async def test_reconfigure_success( hass: HomeAssistant, @@ -164,23 +456,7 @@ async def test_reconfigure_success( assert mock_config_entry.data[CONF_PASSWORD] == TESTPASS -@pytest.mark.parametrize( - ("side_eff", "error"), - [ - ( - HomeeConnectionFailedException("connection timed out"), - {"base": "cannot_connect"}, - ), - ( - HomeeAuthFailedException("wrong username or password"), - {"base": "invalid_auth"}, - ), - ( - Exception, - {"base": "unknown"}, - ), - ], -) +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) async def test_reconfigure_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index a3e26abc52a..4f215c683a2 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -13,6 +13,10 @@ from homeassistant.components.cover import ( CoverEntityFeature, CoverState, ) +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.homee.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -23,9 +27,11 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component from . import build_mock_node, setup_integration @@ -39,6 +45,7 @@ async def test_open_close_stop_cover( ) -> None: """Test opening the cover.""" mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) @@ -73,6 +80,7 @@ async def test_open_close_reverse_cover( ) -> None: """Test opening the cover.""" mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] mock_homee.nodes[0].attributes[0].is_reversed = True await setup_integration(hass, mock_config_entry) @@ -102,6 +110,7 @@ async def test_set_cover_position( ) -> None: """Test setting the cover position.""" mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) @@ -246,6 +255,7 @@ async def test_cover_positions( # Cover open, tilt open. # mock_homee.nodes = [cover] mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] cover = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) @@ -348,3 +358,50 @@ async def test_send_error( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "connection_closed" + + +async def test_node_entity_connection_listener( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if loss of connection is sensed correctly.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + states = hass.states.get("cover.test_cover") + assert states.state != STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[1][0][0](False) + await hass.async_block_till_done() + + states = hass.states.get("cover.test_cover") + assert states.state == STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[1][0][0](True) + await hass.async_block_till_done() + + states = hass.states.get("cover.test_cover") + assert states.state != STATE_UNAVAILABLE + + +async def test_node_entity_update_action( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update_entity action for a HomeeEntity.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + await async_setup_component(hass, HA_DOMAIN, {}) + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + mock_homee.update_node.assert_called_once_with(3) diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py new file mode 100644 index 00000000000..c24cb39295d --- /dev/null +++ b/tests/components/homee/test_init.py @@ -0,0 +1,145 @@ +"""Test Homee initialization.""" + +from unittest.mock import MagicMock + +from pyHomee import HomeeAuthFailedException, HomeeConnectionFailedException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import build_mock_node, setup_integration +from .conftest import HOMEE_ID + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_eff", "config_entry_state", "active_flows"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + ConfigEntryState.SETUP_RETRY, + [], + ), + ( + HomeeAuthFailedException("wrong username or password"), + ConfigEntryState.SETUP_ERROR, + ["reauth"], + ), + ], +) +async def test_connection_errors( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + side_eff: Exception, + config_entry_state: ConfigEntryState, + active_flows: list[str], +) -> None: + """Test if connection errors on startup are handled correctly.""" + mock_homee.get_access_token.side_effect = side_eff + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is config_entry_state + + assert [ + flow["context"]["source"] for flow in hass.config_entries.flow.async_progress() + ] == active_flows + + +async def test_connection_listener( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if loss of connection is sensed correctly.""" + mock_homee.nodes = [build_mock_node("homee.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await mock_homee.add_connection_listener.call_args_list[0][0][0](False) + await hass.async_block_till_done() + assert "Disconnected from Homee" in caplog.text + await mock_homee.add_connection_listener.call_args_list[0][0][0](True) + await hass.async_block_till_done() + assert "Reconnected to Homee" in caplog.text + + +async def test_general_data( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test if data is set correctly.""" + mock_homee.nodes = [ + build_mock_node("cover_with_position_slats.json"), + build_mock_node("homee.json"), + ] + mock_homee.get_node_by_id = ( + lambda node_id: mock_homee.nodes[0] if node_id == 3 else mock_homee.nodes[1] + ) + await setup_integration(hass, mock_config_entry) + + # Verify hub and device created correctly using snapshots. + hub = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}-3")}) + + assert hub == snapshot + assert device == snapshot + + +async def test_software_version( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test sw_version for device with only AttributeType.SOFTWARE_VERSION.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}-3")}) + assert device.sw_version == "1.45" + + +async def test_invalid_profile( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test unknown value passed to get_name_for_enum.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + # This is a profile, that does not exist in the enum. + mock_homee.nodes[0].profile = 77 + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}-3")}) + assert device.model is None + + +async def test_unload_entry( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading of config entry.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 1d4ad4b0f66..b51b3a23b75 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -5,6 +5,10 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.homee.const import ( DOMAIN, OPEN_CLOSE_MAP, @@ -13,9 +17,10 @@ from homeassistant.components.homee.const import ( WINDOW_MAP_REVERSED, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import async_update_attribute_value, build_mock_node, setup_integration from .conftest import HOMEE_ID @@ -168,6 +173,49 @@ async def test_sensor_deprecation_unused_entity( ) +async def test_entity_connection_listener( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if loss of connection is sensed correctly.""" + await setup_sensor(hass, mock_homee, mock_config_entry) + + states = hass.states.get("sensor.test_multisensor_energy_1") + assert states.state is not STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[2][0][0](False) + await hass.async_block_till_done() + + states = hass.states.get("sensor.test_multisensor_energy_1") + assert states.state is STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[2][0][0](True) + await hass.async_block_till_done() + + states = hass.states.get("sensor.test_multisensor_energy_1") + assert states.state is not STATE_UNAVAILABLE + + +async def test_entity_update_action( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update_entity action for a HomeeEntity.""" + await setup_sensor(hass, mock_homee, mock_config_entry) + await async_setup_component(hass, HA_DOMAIN, {}) + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "sensor.test_multisensor_temperature"}, + blocking=True, + ) + + mock_homee.update_attribute.assert_called_once_with(1, 23) + + async def test_sensor_snapshot( hass: HomeAssistant, mock_homee: MagicMock, diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 3f0f0a3c22b..47a9c398d16 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -2,6 +2,7 @@ from datetime import timedelta +from freezegun import freeze_time import pytest from homeassistant.components.homekit.const import ( @@ -22,6 +23,10 @@ from homeassistant.components.homekit.type_switches import ( Valve, ValveSwitch, ) +from homeassistant.components.input_number import ( + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) from homeassistant.components.lawn_mower import ( DOMAIN as LAWN_MOWER_DOMAIN, SERVICE_DOCK, @@ -30,6 +35,7 @@ from homeassistant.components.lawn_mower import ( LawnMowerEntityFeature, ) from homeassistant.components.select import ATTR_OPTIONS +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -658,3 +664,223 @@ async def test_button_switch( await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 + + +async def test_valve_switch_with_set_duration_characteristic( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with set duration characteristic.""" + entity_id = "switch.sprinkler" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "0") + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + {"type": "sprinkler", "linked_valve_duration": "input_number.valve_duration"}, + ) + acc.run() + await hass.async_block_till_done() + + # Assert initial state is synced + assert acc.get_duration() == 0 + + # Simulate setting duration from HomeKit + call_set_value = async_mock_service( + hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE + ) + acc.char_set_duration.client_update_value(300) + await hass.async_block_till_done() + assert call_set_value + assert call_set_value[0].data == { + "entity_id": "input_number.valve_duration", + "value": 300, + } + + # Assert state change in Home Assistant is synced to HomeKit + hass.states.async_set("input_number.valve_duration", "600") + await hass.async_block_till_done() + assert acc.get_duration() == 600 + + # Test fallback if no state is set + hass.states.async_remove("input_number.valve_duration") + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + # Test remaining duration fallback if no end time is linked + assert acc.get_remaining_duration() == 0 + + +async def test_valve_switch_with_remaining_duration_characteristic( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with remaining duration characteristic.""" + entity_id = "switch.sprinkler" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + {"type": "sprinkler", "linked_valve_end_time": "sensor.valve_end_time"}, + ) + acc.run() + await hass.async_block_till_done() + + # Assert initial state is synced + assert acc.get_remaining_duration() == 0 + + # Simulate remaining duration update from Home Assistant + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=90)).isoformat(), + ) + await hass.async_block_till_done() + + # Assert remaining duration is calculated correctly based on end time + assert acc.get_remaining_duration() == 90 + + # Test fallback if no state is set + hass.states.async_remove("sensor.valve_end_time") + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test get duration fallback if no duration is linked + assert acc.get_duration() == 0 + + +async def test_valve_switch_with_duration_characteristics( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with set duration and remaining duration characteristics.""" + entity_id = "switch.sprinkler" + + # Test with duration and end time entities linked + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "300") + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + # Mock input_number service for set_duration calls + call_set_value = async_mock_service( + hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE + ) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + { + "type": "sprinkler", + "linked_valve_duration": "input_number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + ) + acc.run() + await hass.async_block_till_done() + + # Test update_duration_chars with both characteristics + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=60)).isoformat(), + ) + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_set_duration.value == 300 + assert acc.get_remaining_duration() == 60 + + # Test get_duration fallback with invalid state + hass.states.async_set("input_number.valve_duration", "invalid") + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + # Test get_remaining_duration fallback with invalid state + hass.states.async_set("sensor.valve_end_time", "invalid") + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test get_remaining_duration with end time in the past + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() - timedelta(seconds=10)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test set_duration with negative value + acc.set_duration(-10) + await hass.async_block_till_done() + assert acc.get_duration() == 0 + # Verify the service was called with correct parameters + assert len(call_set_value) == 1 + assert call_set_value[0].data == { + "entity_id": "input_number.valve_duration", + "value": -10, + } + + # Test set_duration with negative state + hass.states.async_set("sensor.valve_duration", -10) + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + +async def test_valve_with_duration_characteristics( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve with set duration and remaining duration characteristics.""" + entity_id = "switch.sprinkler" + + # Test with duration and end time entities linked + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "900") + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Using Valve instead of ValveSwitch + acc = Valve( + hass, + hk_driver, + "Valve", + entity_id, + 5, + { + "linked_valve_duration": "input_number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + ) + acc.run() + await hass.async_block_till_done() + + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=600)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.get_duration() == 900 + assert acc.get_remaining_duration() == 600 diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 66906c72266..4cb8eb41489 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -15,6 +15,8 @@ from homeassistant.components.homekit.const import ( CONF_LINKED_BATTERY_SENSOR, CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -128,7 +130,25 @@ def test_validate_entity_config() -> None: } }, {"switch.test": {CONF_TYPE: "invalid_type"}}, + { + "switch.test": { + CONF_TYPE: "sprinkler", + CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number entity + CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity + } + }, {"fan.test": {CONF_TYPE: "invalid_type"}}, + { + "valve.test": { + CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity + CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number + } + }, + { + "valve.test": { + CONF_TYPE: "sprinkler", # Extra keys not allowed + } + }, ] for conf in configs: @@ -212,6 +232,19 @@ def test_validate_entity_config() -> None: assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == { "switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20} } + config = { + CONF_TYPE: TYPE_SPRINKLER, + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + } + assert vec({"switch.sprinkler": config}) == { + "switch.sprinkler": { + CONF_TYPE: TYPE_SPRINKLER, + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, + } + } assert vec({"sensor.co": {CONF_THRESHOLD_CO: 500}}) == { "sensor.co": {CONF_THRESHOLD_CO: 500, CONF_LOW_BATTERY_THRESHOLD: 20} } @@ -244,6 +277,17 @@ def test_validate_entity_config() -> None: CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, } } + config = { + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + } + assert vec({"valve.demo": config}) == { + "valve.demo": { + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, + } + } def test_validate_media_player_features() -> None: diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 882d0d60e66..bf05efada72 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -66,9 +66,7 @@ def fake_ble_discovery() -> Generator[None]: """Fake BLE discovery.""" class FakeBLEDiscovery(FakeDiscovery): - device = BLEDevice( - address="AA:BB:CC:DD:EE:FF", name="TestDevice", rssi=-50, details=() - ) + device = BLEDevice(address="AA:BB:CC:DD:EE:FF", name="TestDevice", details=()) with patch("aiohomekit.testing.FakeDiscovery", FakeBLEDiscovery): yield diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 4540cfd239a..95d24957fcb 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -24,7 +24,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Sleekpoint Innovations', @@ -34,7 +33,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1234', - 'suggested_area': None, 'sw_version': '0.8.16', }), 'entities': list([ @@ -365,14 +363,14 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2576_2580', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'state': dict({ 'attributes': dict({ 'device_class': 'pm25', 'friendly_name': 'Airversa AP2 1808 PM2.5 Density', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'entity_id': 'sensor.airversa_ap2_1808_pm2_5_density', 'state': '3.0', @@ -655,7 +653,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -665,7 +662,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000A', - 'suggested_area': None, 'sw_version': '2.1.6', }), 'entities': list([ @@ -737,7 +733,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -747,7 +742,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000D', - 'suggested_area': None, 'sw_version': '1.6.7', }), 'entities': list([ @@ -995,7 +989,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -1005,7 +998,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000B', - 'suggested_area': None, 'sw_version': '1.6.7', }), 'entities': list([ @@ -1253,7 +1245,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -1263,7 +1254,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000C', - 'suggested_area': None, 'sw_version': '1.6.7', }), 'entities': list([ @@ -1515,7 +1505,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -1525,7 +1514,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00aa00000a0', - 'suggested_area': None, 'sw_version': '3.3.0', }), 'entities': list([ @@ -1736,7 +1724,6 @@ '00:00:00:00:00:00:aid:33', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -1746,7 +1733,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '158d0007c59c6a', - 'suggested_area': None, 'sw_version': '0', }), 'entities': list([ @@ -1913,7 +1899,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -1923,7 +1908,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '0000000123456789', - 'suggested_area': None, 'sw_version': '1.4.7', }), 'entities': list([ @@ -2205,7 +2189,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -2215,7 +2198,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '111a1111a1a111', - 'suggested_area': None, 'sw_version': '9', }), 'entities': list([ @@ -2339,7 +2321,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netgear, Inc', @@ -2349,7 +2330,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00A0000000000', - 'suggested_area': None, 'sw_version': '1.10.931', }), 'entities': list([ @@ -2853,7 +2833,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ConnectSense', @@ -2863,7 +2842,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1020301376', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -3325,7 +3303,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -3335,7 +3312,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB3C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -3500,7 +3476,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -3510,7 +3485,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789012', - 'suggested_area': None, 'sw_version': '4.2.394', }), 'entities': list([ @@ -3982,7 +3956,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -3992,7 +3965,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB1C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4157,7 +4129,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4167,7 +4138,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB2C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4336,7 +4306,6 @@ '00:00:00:00:00:00:aid:4295608960', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4346,7 +4315,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4602,7 +4570,6 @@ '00:00:00:00:00:00:aid:4298360914', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4612,7 +4579,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4861,7 +4827,6 @@ '00:00:00:00:00:00:aid:4298360921', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4871,7 +4836,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5120,7 +5084,6 @@ '00:00:00:00:00:00:aid:4298527970', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5130,7 +5093,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5379,7 +5341,6 @@ '00:00:00:00:00:00:aid:4298527962', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5389,7 +5350,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5638,7 +5598,6 @@ '00:00:00:00:00:00:aid:4295016858', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5648,7 +5607,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5904,7 +5862,6 @@ '00:00:00:00:00:00:aid:4298360712', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5914,7 +5871,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6163,7 +6119,6 @@ '00:00:00:00:00:00:aid:4298649931', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6173,7 +6128,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6422,7 +6376,6 @@ '00:00:00:00:00:00:aid:4295608971', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6432,7 +6385,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6688,7 +6640,6 @@ '00:00:00:00:00:00:aid:4298584118', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6698,7 +6649,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6947,7 +6897,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6957,7 +6906,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '4.8.70226', }), 'entities': list([ @@ -7347,7 +7295,6 @@ '00:00:00:00:00:00:aid:4295016969', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -7357,7 +7304,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -7613,7 +7559,6 @@ '00:00:00:00:00:00:aid:4298568508', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -7623,7 +7568,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -7876,7 +7820,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -7886,7 +7829,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789012', - 'suggested_area': None, 'sw_version': '4.2.394', }), 'entities': list([ @@ -8362,7 +8304,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8372,7 +8313,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB3C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -8487,7 +8427,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8497,7 +8436,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789012', - 'suggested_area': None, 'sw_version': '4.2.394', }), 'entities': list([ @@ -8788,7 +8726,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8798,7 +8735,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB1C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -8963,7 +8899,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8973,7 +8908,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB2C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -9142,7 +9076,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -9152,7 +9085,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789016', - 'suggested_area': None, 'sw_version': '4.7.340214', }), 'entities': list([ @@ -9637,7 +9569,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -9647,7 +9578,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '4.5.130201', }), 'entities': list([ @@ -9948,7 +9878,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Elgato', @@ -9958,7 +9887,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AA00A0A00000', - 'suggested_area': None, 'sw_version': '1.2.8', }), 'entities': list([ @@ -10331,7 +10259,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Elgato', @@ -10341,7 +10268,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AA00A0A00000', - 'suggested_area': None, 'sw_version': '1.2.9', }), 'entities': list([ @@ -10702,7 +10628,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'José A. Jiménez Campos', @@ -10712,7 +10637,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'C718B3-1', - 'suggested_area': None, 'sw_version': '5.0.18', }), 'entities': list([ @@ -10924,7 +10848,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'José A. Jiménez Campos', @@ -10934,7 +10857,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'C718B3-2', - 'suggested_area': None, 'sw_version': '5.0.18', }), 'entities': list([ @@ -11052,7 +10974,6 @@ '00:00:00:00:00:00:aid:123016423', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -11062,7 +10983,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.family_door_north', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -11226,7 +11146,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11236,7 +11155,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -11308,7 +11226,6 @@ '00:00:00:00:00:00:aid:878448248', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -11318,7 +11235,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.kitchen_window', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -11486,7 +11402,6 @@ '00:00:00:00:00:00:aid:766313939', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11496,7 +11411,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.ceiling_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -11619,7 +11533,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11629,7 +11542,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -11701,7 +11613,6 @@ '00:00:00:00:00:00:aid:1256851357', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11711,7 +11622,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.living_room_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -11839,7 +11749,6 @@ '00:00:00:00:00:00:aid:1233851541', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lookin', @@ -11849,7 +11758,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'climate.89_living_room', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12187,7 +12095,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -12197,7 +12104,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12273,7 +12179,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -12283,7 +12188,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12355,7 +12259,6 @@ '00:00:00:00:00:00:aid:3982136094', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'FirstAlert', @@ -12365,7 +12268,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'light.laundry_smoke_ed78', - 'suggested_area': None, 'sw_version': '1.4.84', }), 'entities': list([ @@ -12541,7 +12443,6 @@ '00:00:00:00:00:00:aid:123016423', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -12551,7 +12452,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.family_door_north', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -12715,7 +12615,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -12725,7 +12624,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12797,7 +12695,6 @@ '00:00:00:00:00:00:aid:878448248', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -12807,7 +12704,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.kitchen_window', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -12975,7 +12871,6 @@ '00:00:00:00:00:00:aid:766313939', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -12985,7 +12880,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.ceiling_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13108,7 +13002,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13118,7 +13011,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13190,7 +13082,6 @@ '00:00:00:00:00:00:aid:1256851357', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13200,7 +13091,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.living_room_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13329,7 +13219,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13339,7 +13228,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13411,7 +13299,6 @@ '00:00:00:00:00:00:aid:1256851357', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13421,7 +13308,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.living_room_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13550,7 +13436,6 @@ '00:00:00:00:00:00:aid:1233851541', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lookin', @@ -13560,7 +13445,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'climate.89_living_room', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -13907,7 +13791,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13917,7 +13800,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -13993,7 +13875,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -14003,7 +13884,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14075,7 +13955,6 @@ '00:00:00:00:00:00:aid:293334836', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'switchbot', @@ -14085,7 +13964,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'humidifier.humidifier_182a', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14268,7 +14146,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -14278,7 +14155,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14350,7 +14226,6 @@ '00:00:00:00:00:00:aid:293334836', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'switchbot', @@ -14360,7 +14235,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'humidifier.humidifier_182a', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14543,7 +14417,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -14553,7 +14426,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14625,7 +14497,6 @@ '00:00:00:00:00:00:aid:3982136094', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'FirstAlert', @@ -14635,7 +14506,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'light.laundry_smoke_ed78', - 'suggested_area': None, 'sw_version': '1.4.84', }), 'entities': list([ @@ -14826,7 +14696,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Garzola Marco', @@ -14836,7 +14705,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00000001', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -15040,7 +14908,6 @@ '00:00:00:00:00:00:aid:6623462395276914', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15050,7 +14917,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462395276914', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15187,7 +15053,6 @@ '00:00:00:00:00:00:aid:6623462395276939', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15197,7 +15062,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462395276939', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15334,7 +15198,6 @@ '00:00:00:00:00:00:aid:6623462403113447', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15344,7 +15207,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462403113447', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15481,7 +15343,6 @@ '00:00:00:00:00:00:aid:6623462403233419', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15491,7 +15352,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462403233419', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15628,7 +15488,6 @@ '00:00:00:00:00:00:aid:6623462412411853', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15638,7 +15497,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462412411853', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15785,7 +15643,6 @@ '00:00:00:00:00:00:aid:6623462412413293', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15795,7 +15652,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462412413293', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15942,7 +15798,6 @@ '00:00:00:00:00:00:aid:6623462389072572', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15952,7 +15807,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462389072572', - 'suggested_area': None, 'sw_version': '45.1.17846', }), 'entities': list([ @@ -16276,7 +16130,6 @@ '00:00:00:00:00:00:aid:6623462378982941', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16286,7 +16139,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462378982941', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16410,7 +16262,6 @@ '00:00:00:00:00:00:aid:6623462378983942', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16420,7 +16271,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462378983942', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16544,7 +16394,6 @@ '00:00:00:00:00:00:aid:6623462379122122', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16554,7 +16403,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462379122122', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16678,7 +16526,6 @@ '00:00:00:00:00:00:aid:6623462379123707', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16688,7 +16535,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462379123707', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16812,7 +16658,6 @@ '00:00:00:00:00:00:aid:6623462383114163', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16822,7 +16667,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462383114163', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16946,7 +16790,6 @@ '00:00:00:00:00:00:aid:6623462383114193', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16956,7 +16799,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462383114193', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -17080,7 +16922,6 @@ '00:00:00:00:00:00:aid:6623462385996792', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -17090,7 +16931,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462385996792', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -17214,7 +17054,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips Lighting', @@ -17224,7 +17063,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456', - 'suggested_area': None, 'sw_version': '1.32.1932126170', }), 'entities': list([ @@ -17300,7 +17138,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Koogeek', @@ -17310,7 +17147,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAA011111111111', - 'suggested_area': None, 'sw_version': '2.2.15', }), 'entities': list([ @@ -17453,7 +17289,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Koogeek', @@ -17463,7 +17298,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'EUCP03190xxxxx48', - 'suggested_area': None, 'sw_version': '2.3.7', }), 'entities': list([ @@ -17632,7 +17466,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Koogeek', @@ -17642,7 +17475,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'CNNT061751001372', - 'suggested_area': None, 'sw_version': '1.0.3', }), 'entities': list([ @@ -17852,7 +17684,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lennox', @@ -17862,7 +17693,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'XXXXXXXX', - 'suggested_area': None, 'sw_version': '3.40.XX', }), 'entities': list([ @@ -18152,7 +17982,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'LG Electronics', @@ -18162,7 +17991,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '999AAAAAA999', - 'suggested_area': None, 'sw_version': '04.71.04', }), 'entities': list([ @@ -18344,7 +18172,6 @@ '00:00:00:00:00:00:aid:21474836482', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lutron Electronics Co., Inc', @@ -18354,7 +18181,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '39024290', - 'suggested_area': None, 'sw_version': '001.005', }), 'entities': list([ @@ -18477,7 +18303,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lutron Electronics Co., Inc', @@ -18487,7 +18312,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '12344331', - 'suggested_area': None, 'sw_version': '08.08', }), 'entities': list([ @@ -18563,7 +18387,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Meross', @@ -18573,7 +18396,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'HH41234', - 'suggested_area': None, 'sw_version': '4.2.3', }), 'entities': list([ @@ -18859,7 +18681,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Meross', @@ -18869,7 +18690,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'BB1121', - 'suggested_area': None, 'sw_version': '4.1.9', }), 'entities': list([ @@ -18997,7 +18817,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Empowered Homes Inc.', @@ -19007,7 +18826,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAAAAA000', - 'suggested_area': None, 'sw_version': '2.8.1', }), 'entities': list([ @@ -19347,7 +19165,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Nanoleaf', @@ -19357,7 +19174,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAA011111111111', - 'suggested_area': None, 'sw_version': '1.4.40', }), 'entities': list([ @@ -19632,7 +19448,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -19642,7 +19457,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'g738658', - 'suggested_area': None, 'sw_version': '80.0.0', }), 'entities': list([ @@ -19943,7 +19757,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -19953,7 +19766,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1234', - 'suggested_area': None, 'sw_version': '1.0.3', }), 'entities': list([ @@ -20115,7 +19927,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -20125,7 +19936,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAAAAAAAAAAA', - 'suggested_area': None, 'sw_version': '59', }), 'entities': list([ @@ -20441,7 +20251,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Green Electronics LLC', @@ -20451,7 +20260,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00aa0000aa0a', - 'suggested_area': None, 'sw_version': '1.0.4', }), 'entities': list([ @@ -20887,7 +20695,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -20897,7 +20704,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21061,7 +20867,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21071,7 +20876,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '0101.3521.0436', - 'suggested_area': None, 'sw_version': '1.3.0', }), 'entities': list([ @@ -21143,7 +20947,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21153,7 +20956,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '', - 'suggested_area': None, 'sw_version': '', }), 'entities': list([ @@ -21321,7 +21123,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21331,7 +21132,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21495,7 +21295,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21505,7 +21304,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21669,7 +21467,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21679,7 +21476,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21843,7 +21639,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21853,7 +21648,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '0401.3521.0679', - 'suggested_area': None, 'sw_version': '1.3.0', }), 'entities': list([ @@ -21925,7 +21719,6 @@ '00:00:00:00:00:00:aid:5', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21935,7 +21728,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -22103,7 +21895,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Schlage ', @@ -22113,7 +21904,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAAAAA000', - 'suggested_area': None, 'sw_version': '004.027.000', }), 'entities': list([ @@ -22232,7 +22022,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Hunter Fan', @@ -22242,7 +22031,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1234567890abcd', - 'suggested_area': None, 'sw_version': '', }), 'entities': list([ @@ -22422,7 +22210,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -22432,7 +22219,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '0.0.0', }), 'entities': list([ @@ -22553,7 +22339,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Moen Incorporated', @@ -22563,7 +22348,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '3.3.0', }), 'entities': list([ @@ -22968,7 +22752,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -22978,7 +22761,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '16.0.0', }), 'entities': list([ @@ -23198,7 +22980,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VELUX', @@ -23208,7 +22989,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'a1a11a1', - 'suggested_area': None, 'sw_version': '70', }), 'entities': list([ @@ -23280,7 +23060,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VELUX', @@ -23290,7 +23069,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'a11b111', - 'suggested_area': None, 'sw_version': '16', }), 'entities': list([ @@ -23506,7 +23284,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VELUX', @@ -23516,7 +23293,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1111111a114a111a', - 'suggested_area': None, 'sw_version': '48', }), 'entities': list([ @@ -23637,7 +23413,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -23647,7 +23422,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '0.0.0', }), 'entities': list([ @@ -23768,7 +23542,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -23778,7 +23551,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '15.0.0', }), 'entities': list([ @@ -23898,7 +23670,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VOCOlinc', @@ -23908,7 +23679,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AM01121849000327', - 'suggested_area': None, 'sw_version': '3.121.2', }), 'entities': list([ @@ -24219,7 +23989,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VOCOlinc', @@ -24229,7 +23998,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'EU0121203xxxxx07', - 'suggested_area': None, 'sw_version': '1.101.2', }), 'entities': list([ diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 656978a08a2..86c428b4413 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -328,6 +328,9 @@ async def test_snapshots( device_dict.pop("created_at", None) device_dict.pop("modified_at", None) device_dict.pop("_cache", None) + # This can be removed when suggested_area is removed from DeviceEntry + device_dict.pop("_suggested_area") + device_dict.pop("is_new") devices.append({"device": device_dict, "entities": entities}) diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index 65f8afe55fa..44d8cc33c80 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8297,6 +8297,152 @@ "type": "DOOR_BELL_CONTACT_INTERFACE", "updateState": "UP_TO_DATE" }, + "3014F7110000000000000CTV": { + "availableFirmwareVersion": "0.0.0", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.6", + "firmwareVersionInteger": 65542, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F7110000000000000CTV", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": [ + "00000000-0000-0000-0000-000000000041", + "00000000-0000-0000-0000-000000000042" + ], + "index": 0, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -102, + "rssiPeerValue": null, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "absoluteAngle": 89, + "accelerationSensorEventFilterPeriod": 3.0, + "accelerationSensorMode": "TILT", + "accelerationSensorNeutralPosition": "VERTICAL", + "accelerationSensorSecondTriggerAngle": 75, + "accelerationSensorSensitivity": "SENSOR_RANGE_2G_2PLUS_SENSE", + "accelerationSensorTriggerAngle": 20, + "accelerationSensorTriggered": false, + "channelRole": "ACCELERATION_SENSOR", + "deviceId": "3014F7110000000000000CTV", + "functionalChannelType": "TILT_VIBRATION_SENSOR_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000023", + "00000000-0000-0000-0000-000000000041", + "00000000-0000-0000-0000-000000000043" + ], + "index": 1, + "label": "", + "supportedOptionalFeatures": { + "IFeatureLightGroupSensorChannel": false, + "IOptionalFeatureAbsoluteAngle": true, + "IOptionalFeatureAccelerationSensorTiltTriggerAngle": true, + "IOptionalFeatureTiltDetection": true, + "IOptionalFeatureTiltState": true, + "IOptionalFeatureTiltVisualization": true + }, + "tiltState": "NEUTRAL", + "tiltVisualization": "GARAGE_DOOR" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000CTV", + "label": "Neigungssensor Tor", + "lastStatusUpdate": 1741379260066, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 9, + "measuredAttributes": {}, + "modelId": 580, + "modelType": "ELV-SH-CTV", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F7110000000000000CTV", + "type": "TILT_VIBRATION_SENSOR_COMPACT", + "updateState": "UP_TO_DATE" + }, "3014F71100000000000SVCTH": { "availableFirmwareVersion": "1.0.10", "connectionType": "HMIP_RF", @@ -8420,6 +8566,531 @@ "serializedGlobalTradeItemNumber": "3014F71100000000000SVCTH", "type": "TEMPERATURE_HUMIDITY_SENSOR_COMPACT", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000RGBW2": { + "availableFirmwareVersion": "1.0.62", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "fastColorChangeSupported": true, + "firmwareVersion": "1.0.62", + "firmwareVersionInteger": 65598, + "functionalChannels": { + "0": { + "altitude": null, + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "dataDecodingFailedError": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000RGBW2", + "deviceOperationMode": "UNIVERSAL_LIGHT_1_RGB", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "frostProtectionError": null, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 0, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": null, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -50, + "rssiPeerValue": null, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDataDecodingFailedError": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceMountingModuleError": false, + "IFeatureDeviceOverheated": true, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTempSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeatureNoDataFromLinkyError": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IFeatureTicVersionError": false, + "IOptionalFeatureAltitude": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceFrostProtectionError": false, + "IOptionalFeatureDeviceInputLayoutMode": false, + "IOptionalFeatureDeviceOperationMode": true, + "IOptionalFeatureDeviceSwitchChannelMode": false, + "IOptionalFeatureDeviceValveError": false, + "IOptionalFeatureDeviceWaterError": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureLowBat": false, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": false + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "temperatureSensorError": null, + "ticVersionError": null, + "unreach": false, + "valveFlowError": null, + "valveWaterError": null + }, + "1": { + "channelActive": true, + "channelRole": "UNIVERSAL_LIGHT_ACTUATOR", + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": false, + "dimLevel": 0.68, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000061"], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": 120, + "humanCentricLightActive": false, + "index": 1, + "label": "", + "lampFailure": null, + "lightSceneId": 1, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": true, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": 0.8, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLightGroupActuatorChannel": true, + "IFeatureLightProfileActuatorChannel": true, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": true, + "IOptionalFeatureLightScene": true, + "IOptionalFeatureLightSceneWithShortTimes": true, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": true, + "IOptionalFeaturePowerUpHueSaturationValue": true, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "2": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 2, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "3": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 3, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "4": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 4, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000RGBW2", + "label": "RGBW Controller", + "lastStatusUpdate": 1749973334235, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 1, + "measuredAttributes": {}, + "modelId": 462, + "modelType": "HmIP-RGBW", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F71100000000000RGBW2", + "type": "RGBW_DIMMER", + "updateState": "UP_TO_DATE" + }, + "3014F71100000000000SHWSM": { + "availableFirmwareVersion": "0.0.0", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.10", + "firmwareVersionInteger": 65546, + "functionalChannels": { + "0": { + "altitude": null, + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "dataDecodingFailedError": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000SHWSM", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "frostProtectionError": false, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000022"], + "index": 0, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -46, + "rssiPeerValue": -43, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDataDecodingFailedError": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceMountingModuleError": false, + "IFeatureDeviceOverheated": true, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTempSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": true, + "IFeatureMulticastRouter": false, + "IFeatureNoDataFromLinkyError": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IFeatureTicVersionError": false, + "IOptionalFeatureAltitude": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceFrostProtectionError": true, + "IOptionalFeatureDeviceInputLayoutMode": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDeviceSwitchChannelMode": false, + "IOptionalFeatureDeviceValveError": true, + "IOptionalFeatureDeviceWaterError": true, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "temperatureSensorError": null, + "ticVersionError": null, + "unreach": false, + "valveFlowError": false, + "valveWaterError": false + }, + "1": { + "channelRole": "WATERING_ACTUATOR", + "deviceId": "3014F71100000000000SHWSM", + "functionalChannelType": "WATERING_ACTUATOR_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000023"], + "index": 1, + "label": "", + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": { + "IFeatureWateringGroupActuatorChannel": true, + "IFeatureWateringProfileActuatorChannel": true + }, + "userDesiredProfileMode": "AUTOMATIC", + "waterFlow": 12.0, + "waterVolume": 455.0, + "waterVolumeSinceOpen": 67.0, + "wateringActive": false, + "wateringOnTime": 3600.0 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000SHWSM", + "label": "Bewaesserungsaktor", + "lastStatusUpdate": 1749501203047, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 9, + "measuredAttributes": {}, + "modelId": 586, + "modelType": "ELV-SH-WSM", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F71100000000000SHWSM", + "type": "WATERING_ACTUATOR", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index b005090309b..9b152988c24 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -365,14 +365,16 @@ async def test_hmip_garage_door_tormatic( assert ha_state.state == "closed" assert ha_state.attributes["current_position"] == 0 - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.OPEN @@ -381,9 +383,11 @@ async def test_hmip_garage_door_tormatic( await hass.services.async_call( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.CLOSED @@ -392,9 +396,11 @@ async def test_hmip_garage_door_tormatic( await hass.services.async_call( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.STOP,) async def test_hmip_garage_door_hoermann( @@ -414,14 +420,16 @@ async def test_hmip_garage_door_hoermann( assert ha_state.state == "closed" assert ha_state.attributes["current_position"] == 0 - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.OPEN @@ -430,9 +438,11 @@ async def test_hmip_garage_door_hoermann( await hass.services.async_call( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.CLOSED @@ -441,9 +451,11 @@ async def test_hmip_garage_door_hoermann( await hass.services.async_call( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.STOP,) async def test_hmip_cover_shutter_group( diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index abd0e18b368..8bff1798255 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 325 + assert len(mock_hap.hmip_device_by_entity_id) == 340 async def test_hmip_remove_device( @@ -195,9 +195,14 @@ async def test_hap_reconnected( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNAVAILABLE - mock_hap._accesspoint_connected = False - await async_manipulate_test_data(hass, mock_hap.home, "connected", True) - await hass.async_block_till_done() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await async_manipulate_test_data(hass, mock_hap.home, "connected", True) + await mock_hap.ws_connected_handler() + await hass.async_block_till_done() + ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ae094f7dded..69078beafaf 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -242,7 +242,14 @@ async def test_get_state_after_disconnect( hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch.object(hap, "get_state") as mock_get_state: + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + hap.home = simple_mock_home + hap.home.websocket_is_connected = Mock(side_effect=[False, True]) + + with ( + patch("asyncio.sleep", new=AsyncMock()) as mock_sleep, + patch.object(hap, "get_state") as mock_get_state, + ): assert not hap._ws_connection_closed.is_set() await hap.ws_connected_handler() @@ -250,8 +257,54 @@ async def test_get_state_after_disconnect( await hap.ws_disconnected_handler() assert hap._ws_connection_closed.is_set() - await hap.ws_connected_handler() - mock_get_state.assert_called_once() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await hap.ws_connected_handler() + mock_get_state.assert_called_once() + + assert not hap._ws_connection_closed.is_set() + hap.home.websocket_is_connected.assert_called() + mock_sleep.assert_awaited_with(2) + + +async def test_try_get_state_exponential_backoff() -> None: + """Test _try_get_state waits for websocket connection.""" + + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + hap.home.websocket_is_connected = Mock(return_value=True) + + hap.get_state = AsyncMock( + side_effect=[HmipConnectionError, HmipConnectionError, True] + ) + + with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep: + await hap._try_get_state() + + assert mock_sleep.mock_calls[0].args[0] == 8 + assert mock_sleep.mock_calls[1].args[0] == 16 + assert hap.get_state.call_count == 3 + + +async def test_try_get_state_handle_exception() -> None: + """Test _try_get_state handles exceptions.""" + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + + expected_exception = Exception("Connection error") + future = AsyncMock() + future.result = Mock(side_effect=expected_exception) + + with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as mock_logger: + hap.get_state_finished(future) + + mock_logger.error.assert_called_once_with( + "Error updating state after HMIP access point reconnect: %s", expected_exception + ) async def test_async_connect( diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index b929bd337cc..85106f2d987 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -600,3 +600,79 @@ async def test_hmip_din_rail_dimmer_3_channel3( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF assert not ha_state.attributes.get(ATTR_BRIGHTNESS) + + +async def test_hmip_light_hs( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipLight with HS color mode.""" + entity_id = "light.rgbw_controller_channel1" + entity_name = "RGBW Controller Channel1" + device_model = "HmIP-RGBW" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["RGBW Controller"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == STATE_ON + assert ha_state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [240.0, 100.0]}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 240.0, + "saturation_level": 1.0, + "dim_level": 0.68, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [220.0, 80.0], ATTR_BRIGHTNESS: 123}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 220.0, + "saturation_level": 0.8, + "dim_level": 0.48, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_BRIGHTNESS: 40}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": hmip_device.functionalChannels[1].hue, + "saturation_level": hmip_device.functionalChannels[1].saturationLevel, + "dim_level": 0.16, + } diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 3b5773cfa4d..669cbbf664f 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -13,6 +13,9 @@ from homeassistant.components.homematicip_cloud.entity import ( ) from homeassistant.components.homematicip_cloud.hap import HomematicipHAP from homeassistant.components.homematicip_cloud.sensor import ( + ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, + ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE, + ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE, ATTR_CURRENT_ILLUMINATION, ATTR_HIGHEST_ILLUMINATION, ATTR_LEFT_COUNTER, @@ -32,6 +35,8 @@ from homeassistant.const import ( UnitOfPower, UnitOfSpeed, UnitOfTemperature, + UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant @@ -708,6 +713,54 @@ async def test_hmip_esi_led_energy_counter_usage_high_tariff( assert ha_state.state == "23825.748" +async def test_hmip_tilt_vibration_sensor_tilt_state( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipTiltVibrationSensor.""" + entity_id = "sensor.neigungssensor_tor_tilt_state" + entity_name = "Neigungssensor Tor Tilt State" + device_model = "ELV-SH-CTV" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Neigungssensor Tor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "neutral" + + await async_manipulate_test_data(hass, hmip_device, "tiltState", "NON_NEUTRAL", 1) + ha_state = hass.states.get(entity_id) + assert ha_state.state == "non_neutral" + + await async_manipulate_test_data(hass, hmip_device, "tiltState", "TILTED", 1) + ha_state = hass.states.get(entity_id) + assert ha_state.state == "tilted" + + assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION] == "VERTICAL" + assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE] == 20 + assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE] == 75 + + +async def test_hmip_tilt_vibration_sensor_tilt_angle( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipTiltVibrationSensor.""" + entity_id = "sensor.neigungssensor_tor_tilt_angle" + entity_name = "Neigungssensor Tor Tilt Angle" + device_model = "ELV-SH-CTV" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Neigungssensor Tor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "89" + + async def test_hmip_absolute_humidity_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -723,7 +776,7 @@ async def test_hmip_absolute_humidity_sensor( hass, mock_hap, entity_id, entity_name, device_model ) - assert ha_state.state == "6098" + assert ha_state.state == "6099.0" async def test_hmip_absolute_humidity_sensor_invalid_value( @@ -745,3 +798,66 @@ async def test_hmip_absolute_humidity_sensor_invalid_value( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNKNOWN + + +async def test_hmip_water_valve_current_water_flow( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipCurrentWaterFlow.""" + entity_id = "sensor.bewaesserungsaktor_currentwaterflow" + entity_name = "Bewaesserungsaktor currentWaterFlow" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "12.0" + assert ( + ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + == UnitOfVolumeFlowRate.LITERS_PER_MINUTE + ) + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + +async def test_hmip_water_valve_water_volume( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipWaterVolume.""" + entity_id = "sensor.bewaesserungsaktor_watervolume" + entity_name = "Bewaesserungsaktor waterVolume" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "455.0" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfVolume.LITERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING + + +async def test_hmip_water_valve_water_volume_since_open( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipWaterVolumeSinceOpen.""" + entity_id = "sensor.bewaesserungsaktor_watervolumesinceopen" + entity_name = "Bewaesserungsaktor waterVolumeSinceOpen" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "67.0" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfVolume.LITERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/homematicip_cloud/test_valve.py b/tests/components/homematicip_cloud/test_valve.py new file mode 100644 index 00000000000..5c2840dc28f --- /dev/null +++ b/tests/components/homematicip_cloud/test_valve.py @@ -0,0 +1,35 @@ +"""Test HomematicIP Cloud valve entities.""" + +from homeassistant.components.valve import SERVICE_OPEN_VALVE, ValveState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics + + +async def test_watering_valve( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicIP watering valve.""" + entity_id = "valve.bewaesserungsaktor_watering" + entity_name = "Bewaesserungsaktor watering" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == ValveState.CLOSED + + await hass.services.async_call( + Platform.VALVE, SERVICE_OPEN_VALVE, {"entity_id": entity_id}, blocking=True + ) + + await async_manipulate_test_data( + hass, hmip_device, "wateringActive", True, channel=1 + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == ValveState.OPEN diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index a07c0745c45..967672580ec 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -70,7 +70,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -80,7 +79,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 3224a0cc63e..972b7fc5728 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -79,7 +79,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -89,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -174,7 +172,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -184,7 +181,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index ecfd80e04da..0797256120c 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -80,7 +80,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -90,7 +89,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 9f95e140edc..1bde08b3201 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -21,7 +21,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -109,7 +107,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -119,7 +116,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -202,7 +198,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -212,7 +207,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -295,7 +289,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -305,7 +298,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -388,7 +380,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -398,7 +389,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -481,7 +471,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -491,7 +480,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -574,7 +562,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -584,7 +571,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -667,7 +653,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -677,7 +662,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -753,7 +737,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -763,7 +746,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -846,7 +828,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -856,7 +837,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -935,7 +915,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -945,7 +924,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -1020,7 +998,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1030,7 +1007,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1113,7 +1089,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1123,7 +1098,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1206,7 +1180,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1216,7 +1189,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1299,7 +1271,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1309,7 +1280,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1392,7 +1362,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1402,7 +1371,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1485,7 +1453,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1495,7 +1462,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1578,7 +1544,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1588,7 +1553,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1668,7 +1632,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1678,7 +1641,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1761,7 +1723,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1771,7 +1732,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1854,7 +1814,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1864,7 +1823,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1939,7 +1897,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1949,7 +1906,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2028,7 +1984,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2038,7 +1993,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2121,7 +2075,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2131,7 +2084,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2214,7 +2166,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2224,7 +2175,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2307,7 +2257,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2317,7 +2266,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2400,7 +2348,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2410,7 +2357,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2493,7 +2439,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2503,7 +2448,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2586,7 +2530,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2596,7 +2539,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2679,7 +2621,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2689,7 +2630,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2772,7 +2712,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2782,7 +2721,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2865,7 +2803,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2875,7 +2812,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2958,7 +2894,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2968,7 +2903,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3051,7 +2985,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3061,7 +2994,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3144,7 +3076,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3154,7 +3085,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3234,7 +3164,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3244,7 +3173,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3324,7 +3252,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3334,7 +3261,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3414,7 +3340,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3424,7 +3349,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3507,7 +3431,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3517,7 +3440,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3600,7 +3522,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3610,7 +3531,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3693,7 +3613,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3703,7 +3622,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3786,7 +3704,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3796,7 +3713,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3879,7 +3795,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3889,7 +3804,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3972,7 +3886,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3982,7 +3895,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4065,7 +3977,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4075,7 +3986,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4158,7 +4068,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4168,7 +4077,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4251,7 +4159,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4261,7 +4168,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4344,7 +4250,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4354,7 +4259,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4429,7 +4333,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4439,7 +4342,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4518,7 +4420,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4528,7 +4429,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4608,7 +4508,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4618,7 +4517,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4701,7 +4599,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4711,7 +4608,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4794,7 +4690,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4804,7 +4699,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4887,7 +4781,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4897,7 +4790,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4972,7 +4864,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4982,7 +4873,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5065,7 +4955,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5075,7 +4964,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5158,7 +5046,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5168,7 +5055,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5251,7 +5137,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5261,7 +5146,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5344,7 +5228,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5354,7 +5237,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5437,7 +5319,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5447,7 +5328,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5530,7 +5410,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5540,7 +5419,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5623,7 +5501,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5633,7 +5510,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5716,7 +5592,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5726,7 +5601,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5809,7 +5683,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5819,7 +5692,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5902,7 +5774,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5912,7 +5783,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5995,7 +5865,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6005,7 +5874,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6080,7 +5948,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6090,7 +5957,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6170,7 +6036,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6180,7 +6045,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6263,7 +6127,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6273,7 +6136,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6348,7 +6210,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6358,7 +6219,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6441,7 +6301,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6451,7 +6310,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6534,7 +6392,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6544,7 +6401,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6627,7 +6483,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6637,7 +6492,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6712,7 +6566,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6722,7 +6575,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6797,7 +6649,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6807,7 +6658,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6896,7 +6746,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6906,7 +6755,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6989,7 +6837,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6999,7 +6846,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7082,7 +6928,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7092,7 +6937,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7175,7 +7019,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7185,7 +7028,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7268,7 +7110,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7278,7 +7119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7353,7 +7193,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7363,7 +7202,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7438,7 +7276,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7448,7 +7285,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7523,7 +7359,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7533,7 +7368,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7608,7 +7442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7618,7 +7451,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7693,7 +7525,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7703,7 +7534,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7778,7 +7608,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7788,7 +7617,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7867,7 +7695,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7877,7 +7704,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7952,7 +7778,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7962,7 +7787,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8037,7 +7861,6 @@ 'gas_meter_G001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8047,7 +7870,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'gas_meter_G001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8126,7 +7948,6 @@ 'heat_meter_H001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8136,7 +7957,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'heat_meter_H001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8215,7 +8035,6 @@ 'inlet_heat_meter_IH001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8225,7 +8044,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8300,7 +8118,6 @@ 'warm_water_meter_WW001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8310,7 +8127,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8389,7 +8205,6 @@ 'water_meter_W001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8399,7 +8214,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'water_meter_W001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8482,7 +8296,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8492,7 +8305,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8572,7 +8384,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8582,7 +8393,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8665,7 +8475,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8675,7 +8484,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8758,7 +8566,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8768,7 +8575,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8851,7 +8657,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8861,7 +8666,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8936,7 +8740,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8946,7 +8749,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9029,7 +8831,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9039,7 +8840,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9122,7 +8922,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9132,7 +8931,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9215,7 +9013,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9225,7 +9022,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9308,7 +9104,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9318,7 +9113,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9401,7 +9195,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9411,7 +9204,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9494,7 +9286,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9504,7 +9295,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9587,7 +9377,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9597,7 +9386,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9680,7 +9468,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9690,7 +9477,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9773,7 +9559,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9783,7 +9568,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9866,7 +9650,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9876,7 +9659,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9959,7 +9741,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9969,7 +9750,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10044,7 +9824,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10054,7 +9833,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10134,7 +9912,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10144,7 +9921,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10227,7 +10003,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10237,7 +10012,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10312,7 +10086,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10322,7 +10095,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10405,7 +10177,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10415,7 +10186,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10498,7 +10268,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10508,7 +10277,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10591,7 +10359,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10601,7 +10368,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10676,7 +10442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10686,7 +10451,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10761,7 +10525,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10771,7 +10534,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10860,7 +10622,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10870,7 +10631,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10953,7 +10713,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10963,7 +10722,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11046,7 +10804,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11056,7 +10813,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11139,7 +10895,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11149,7 +10904,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11232,7 +10986,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11242,7 +10995,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11317,7 +11069,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11327,7 +11078,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11402,7 +11152,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11412,7 +11161,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11487,7 +11235,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11497,7 +11244,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11572,7 +11318,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11582,7 +11327,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11657,7 +11401,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11667,7 +11410,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11742,7 +11484,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11752,7 +11493,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11831,7 +11571,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11841,7 +11580,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11916,7 +11654,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11926,7 +11663,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12001,7 +11737,6 @@ 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12011,7 +11746,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12090,7 +11824,6 @@ 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12100,7 +11833,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12179,7 +11911,6 @@ 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12189,7 +11920,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12264,7 +11994,6 @@ 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12274,7 +12003,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12353,7 +12081,6 @@ 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12363,7 +12090,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12446,7 +12172,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12456,7 +12181,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12536,7 +12260,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12546,7 +12269,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12629,7 +12351,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12639,7 +12360,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12722,7 +12442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12732,7 +12451,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12815,7 +12533,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12825,7 +12542,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12908,7 +12624,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12918,7 +12633,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13001,7 +12715,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13011,7 +12724,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13094,7 +12806,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13104,7 +12815,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13187,7 +12897,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13197,7 +12906,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13280,7 +12988,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13290,7 +12997,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13373,7 +13079,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13383,7 +13088,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13466,7 +13170,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13476,7 +13179,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13559,7 +13261,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13569,7 +13270,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13652,7 +13352,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13662,7 +13361,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13745,7 +13443,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13755,7 +13452,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13838,7 +13534,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13848,7 +13543,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13923,7 +13617,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13933,7 +13626,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14016,7 +13708,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14026,7 +13717,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14101,7 +13791,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14111,7 +13800,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14194,7 +13882,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14204,7 +13891,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14287,7 +13973,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14297,7 +13982,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14380,7 +14064,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14390,7 +14073,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14473,7 +14155,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14483,7 +14164,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14566,7 +14246,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14576,7 +14255,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14659,7 +14337,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14669,7 +14346,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14752,7 +14428,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14762,7 +14437,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14837,7 +14511,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14847,7 +14520,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14922,7 +14594,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14932,7 +14603,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15007,7 +14677,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15017,7 +14686,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15092,7 +14760,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15102,7 +14769,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15177,7 +14843,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15187,7 +14852,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15262,7 +14926,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15272,7 +14935,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15351,7 +15013,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15361,7 +15022,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15436,7 +15096,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15446,7 +15105,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15525,7 +15183,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15535,7 +15192,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15618,7 +15274,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15628,7 +15283,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15711,7 +15365,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15721,7 +15374,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15804,7 +15456,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15814,7 +15465,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15897,7 +15547,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15907,7 +15556,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15982,7 +15630,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15992,7 +15639,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -16071,7 +15717,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16081,7 +15726,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16164,7 +15808,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16174,7 +15817,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16257,7 +15899,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16267,7 +15908,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16350,7 +15990,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16360,7 +15999,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16443,7 +16081,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16453,7 +16090,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16536,7 +16172,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16546,7 +16181,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16629,7 +16263,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16639,7 +16272,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16719,7 +16351,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16729,7 +16360,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16812,7 +16442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16822,7 +16451,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16905,7 +16533,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16915,7 +16542,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16998,7 +16624,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17008,7 +16633,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -17083,7 +16707,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17093,7 +16716,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -17172,7 +16794,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17182,7 +16803,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17265,7 +16885,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17275,7 +16894,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17354,7 +16972,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17364,7 +16981,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17439,7 +17055,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17449,7 +17064,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17528,7 +17142,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17538,7 +17151,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17621,7 +17233,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17631,7 +17242,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17714,7 +17324,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17724,7 +17333,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17807,7 +17415,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17817,7 +17424,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17900,7 +17506,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17910,7 +17515,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17993,7 +17597,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18003,7 +17606,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18086,7 +17688,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18096,7 +17697,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18176,7 +17776,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18186,7 +17785,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18269,7 +17867,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18279,7 +17876,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18362,7 +17958,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18372,7 +17967,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18447,7 +18041,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18457,7 +18050,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18536,7 +18128,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18546,7 +18137,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18629,7 +18219,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18639,7 +18228,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18722,7 +18310,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18732,7 +18319,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18815,7 +18401,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18825,7 +18410,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18908,7 +18492,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18918,7 +18501,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19001,7 +18583,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19011,7 +18592,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19094,7 +18674,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19104,7 +18683,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19187,7 +18765,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19197,7 +18774,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19280,7 +18856,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19290,7 +18865,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19373,7 +18947,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19383,7 +18956,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19466,7 +19038,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19476,7 +19047,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19559,7 +19129,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19569,7 +19138,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19652,7 +19220,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19662,7 +19229,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19742,7 +19308,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19752,7 +19317,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19832,7 +19396,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19842,7 +19405,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19922,7 +19484,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19932,7 +19493,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20015,7 +19575,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20025,7 +19584,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20108,7 +19666,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20118,7 +19675,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20201,7 +19757,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20211,7 +19766,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20294,7 +19848,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20304,7 +19857,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20387,7 +19939,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20397,7 +19948,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20480,7 +20030,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20490,7 +20039,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20573,7 +20121,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20583,7 +20130,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20666,7 +20212,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20676,7 +20221,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20759,7 +20303,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20769,7 +20312,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20852,7 +20394,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20862,7 +20403,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20937,7 +20477,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20947,7 +20486,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index c4e67003b58..d61979c84b5 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -69,7 +69,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -79,7 +78,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -154,7 +152,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -164,7 +161,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -240,7 +236,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -250,7 +245,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -325,7 +319,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -335,7 +328,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -410,7 +402,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -420,7 +411,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -496,7 +486,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -506,7 +495,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -581,7 +569,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -591,7 +578,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -666,7 +652,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -676,7 +661,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -751,7 +735,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -761,7 +744,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -836,7 +818,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -846,7 +827,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -921,7 +901,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -931,7 +910,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 8bf2e66a286..ca66b8fef4b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -305,16 +305,22 @@ async def test_auth_access_signed_path_with_refresh_token( hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id ) + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + req = await client.get(signed_path) assert req.status == HTTPStatus.OK data = await req.json() assert data["user_id"] == refresh_token.user.id # Use signature on other path + req = await client.head(f"/another_path?{signed_path.split('?')[1]}") + assert req.status == HTTPStatus.UNAUTHORIZED + req = await client.get(f"/another_path?{signed_path.split('?')[1]}") assert req.status == HTTPStatus.UNAUTHORIZED - # We only allow GET + # We only allow GET and HEAD req = await client.post(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 7858bbc123d..195a291b140 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -522,24 +522,6 @@ async def test_logging( assert "GET /api/states/logging.entity" not in caplog.text -async def test_register_static_paths( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test registering a static path with old api.""" - assert await async_setup_component(hass, "frontend", {}) - path = str(Path(__file__).parent) - - match_error = ( - "Detected code that calls hass.http.register_static_path " - "which does blocking I/O in the event loop, instead call " - "`await hass.http.async_register_static_paths" - ) - with pytest.raises(RuntimeError, match=match_error): - hass.http.register_static_path("/something", path) - - async def test_ssl_issue_if_no_urls_configured( hass: HomeAssistant, tmp_path: Path, diff --git a/tests/components/huawei_lte/__init__.py b/tests/components/huawei_lte/__init__.py index 2d43a5eade1..f9f16a2473c 100644 --- a/tests/components/huawei_lte/__init__.py +++ b/tests/components/huawei_lte/__init__.py @@ -21,3 +21,320 @@ def magic_client(multi_basic_settings_value: dict) -> MagicMock: wifi_feature_switch=wifi_feature_switch, ) return MagicMock(device=device, monitoring=monitoring, wlan=wlan) + + +def magic_client_full() -> MagicMock: + """Extended mock for huawei_lte.Client with all API methods.""" + information = MagicMock( + return_value={ + "DeviceName": "Test Router", + "SerialNumber": "test-serial-number", + "Imei": "123456789012345", + "Imsi": "123451234567890", + "Iccid": "12345678901234567890", + "Msisdn": None, + "HardwareVersion": "1.0.0", + "SoftwareVersion": "2.0.0", + "WebUIVersion": "3.0.0", + "MacAddress1": "22:22:33:44:55:66", + "MacAddress2": None, + "WanIPAddress": "23.215.0.138", + "wan_dns_address": "8.8.8.8", + "WanIPv6Address": "2600:1406:3a00:21::173e:2e66", + "wan_ipv6_dns_address": "2001:4860:4860:0:0:0:0:8888", + "ProductFamily": "LTE", + "Classify": "cpe", + "supportmode": "LTE|WCDMA|GSM", + "workmode": "LTE", + "submask": "255.255.255.255", + "Mccmnc": "20499", + "iniversion": "test-ini-version", + "uptime": "4242424", + "ImeiSvn": "01", + "WifiMacAddrWl0": "22:22:33:44:55:77", + "WifiMacAddrWl1": "22:22:33:44:55:88", + "spreadname_en": "Huawei 4G Router N123", + "spreadname_zh": "\u534e\u4e3a4G\u8def\u7531 N123", + } + ) + basic_information = MagicMock( + return_value={ + "classify": "cpe", + "devicename": "Test Router", + "multimode": "0", + "productfamily": "LTE", + "restore_default_status": "0", + "sim_save_pin_enable": "1", + "spreadname_en": "Huawei 4G Router N123", + "spreadname_zh": "\u534e\u4e3a4G\u8def\u7531 N123", + } + ) + signal = MagicMock( + return_value={ + "pci": "123", + "sc": None, + "cell_id": "12345678", + "rssi": "-70dBm", + "rsrp": "-100dBm", + "rsrq": "-10.0dB", + "sinr": "10dB", + "rscp": None, + "ecio": None, + "mode": "7", + "ulbandwidth": "20MHz", + "dlbandwidth": "20MHz", + "txpower": "PPusch:-1dBm PPucch:-11dBm PSrs:10dBm PPrach:0dBm", + "tdd": None, + "ul_mcs": "mcsUpCarrier1:20", + "dl_mcs": "mcsDownCarrier1Code0:8 mcsDownCarrier1Code1:9", + "earfcn": "DL:123 UL:45678", + "rrc_status": "1", + "rac": None, + "lac": None, + "tac": "12345", + "band": "1", + "nei_cellid": "23456789", + "plmn": "20499", + "ims": "0", + "wdlfreq": None, + "lteulfreq": "19697", + "ltedlfreq": "21597", + "transmode": "TM[4]", + "enodeb_id": "0012345", + "cqi0": "11", + "cqi1": "5", + "ulfrequency": "1969700kHz", + "dlfrequency": "2159700kHz", + "arfcn": None, + "bsic": None, + "rxlev": None, + } + ) + + check_notifications = MagicMock( + return_value={ + "UnreadMessage": "2", + "SmsStorageFull": "0", + "OnlineUpdateStatus": "42", + "SimOperEvent": "0", + } + ) + status = MagicMock( + return_value={ + "ConnectionStatus": "901", + "WifiConnectionStatus": None, + "SignalStrength": None, + "SignalIcon": "5", + "CurrentNetworkType": "19", + "CurrentServiceDomain": "3", + "RoamingStatus": "0", + "BatteryStatus": None, + "BatteryLevel": None, + "BatteryPercent": None, + "simlockStatus": "0", + "PrimaryDns": "8.8.8.8", + "SecondaryDns": "8.8.4.4", + "wififrequence": "1", + "flymode": "0", + "PrimaryIPv6Dns": "2001:4860:4860:0:0:0:0:8888", + "SecondaryIPv6Dns": "2001:4860:4860:0:0:0:0:8844", + "CurrentWifiUser": "42", + "TotalWifiUser": "64", + "currenttotalwifiuser": "0", + "ServiceStatus": "2", + "SimStatus": "1", + "WifiStatus": "1", + "CurrentNetworkTypeEx": "101", + "maxsignal": "5", + "wifiindooronly": "0", + "cellroam": "1", + "classify": "cpe", + "usbup": "0", + "wifiswitchstatus": "1", + "WifiStatusExCustom": "0", + "hvdcp_online": "0", + } + ) + month_statistics = MagicMock( + return_value={ + "CurrentMonthDownload": "1000000000", + "CurrentMonthUpload": "500000000", + "MonthDuration": "720000", + "MonthLastClearTime": "2025-07-01", + "CurrentDayUsed": "123456789", + "CurrentDayDuration": "10000", + } + ) + traffic_statistics = MagicMock( + return_value={ + "CurrentConnectTime": "123456", + "CurrentUpload": "2000000000", + "CurrentDownload": "5000000000", + "CurrentDownloadRate": "700", + "CurrentUploadRate": "600", + "TotalUpload": "20000000000", + "TotalDownload": "50000000000", + "TotalConnectTime": "1234567", + "showtraffic": "1", + } + ) + + current_plmn = MagicMock( + return_value={ + "State": "1", + "FullName": "Test Network", + "ShortName": "Test", + "Numeric": "12345", + } + ) + net_mode = MagicMock( + return_value={ + "NetworkMode": "03", + "NetworkBand": "3FFFFFFF", + "LTEBand": "7FFFFFFFFFFFFFFF", + } + ) + + sms_count = MagicMock( + return_value={ + "LocalUnread": "0", + "LocalInbox": "5", + "LocalOutbox": "2", + "LocalDraft": "1", + "LocalDeleted": "0", + "SimUnread": "0", + "SimInbox": "0", + "SimOutbox": "0", + "SimDraft": "0", + "LocalMax": "500", + "SimMax": "30", + "SimUsed": "0", + "NewMsg": "0", + } + ) + + mobile_dataswitch = MagicMock(return_value={"dataswitch": "1"}) + + lan_host_info = MagicMock( + return_value={ + "Hosts": { + "Host": [ + { + "Active": "0", + "ActualName": "TestDevice1", + "AddressSource": "DHCP", + "AssociatedSsid": None, + "AssociatedTime": None, + "HostName": "TestDevice1", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.9.", + "InterfaceType": "Wireless", + "IpAddress": "192.168.1.100", + "LeaseTime": "2204542", + "MacAddress": "AA:BB:CC:DD:EE:FF", + "isLocalDevice": "0", + }, + { + "Active": "1", + "ActualName": "TestDevice2", + "AddressSource": "DHCP", + "AssociatedSsid": "TestSSID", + "AssociatedTime": "258632", + "HostName": "TestDevice2", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.17.", + "InterfaceType": "Wireless", + "IpAddress": "192.168.1.101", + "LeaseTime": "552115", + "MacAddress": "11:22:33:44:55:66", + "isLocalDevice": "0", + }, + ] + } + } + ) + wlan_host_list = MagicMock( + return_value={ + "Hosts": { + "Host": [ + { + "ActualName": "TestDevice2", + "AssociatedSsid": "TestSSID", + "AssociatedTime": "258632", + "Frequency": "2.4GHz", + "HostName": "TestDevice2", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.17.", + "IpAddress": "192.168.1.101;fe80::b222:33ff:fe44:5566", + "MacAddress": "11:22:33:44:55:66", + } + ] + } + } + ) + multi_basic_settings = MagicMock( + return_value={"Ssid": [{"wifiisguestnetwork": "1", "WifiEnable": "0"}]} + ) + wifi_feature_switch = MagicMock( + return_value={ + "wifi_dbdc_enable": "0", + "acmode_enable": "1", + "wifiautocountry_enabled": "0", + "wps_cancel_enable": "1", + "wifimacfilterextendenable": "1", + "wifimaxmacfilternum": "32", + "paraimmediatework_enable": "1", + "guestwifi_enable": "0", + "wifi5gnamepostfix": "_5G", + "wifiguesttimeextendenable": "1", + "chinesessid_enable": "0", + "isdoublechip": "1", + "opennonewps_enable": "1", + "wifi_country_enable": "0", + "wifi5g_enabled": "1", + "wifiwpsmode": "0", + "pmf_enable": "1", + "support_trigger_dualband_wps": "1", + "maxapnum": "4", + "wifi_chip_maxassoc": "32", + "wifiwpssuportwepnone": "0", + "maxassocoffloadon": None, + "guidefrequencyenable": "0", + "showssid_enable": "0", + "wifishowradioswitch": "3", + "wifispecialcharenable": "1", + "wifi24g_switch_enable": "1", + "wifi_dfs_enable": "0", + "show_maxassoc": "0", + "hilink_dbho_enable": "1", + "oledshowpassword": "1", + "doubleap5g_enable": "0", + "wps_switch_enable": "1", + } + ) + + device = MagicMock( + information=information, basic_information=basic_information, signal=signal + ) + monitoring = MagicMock( + check_notifications=check_notifications, + status=status, + month_statistics=month_statistics, + traffic_statistics=traffic_statistics, + ) + net = MagicMock(current_plmn=current_plmn, net_mode=net_mode) + sms = MagicMock(sms_count=sms_count) + dial_up = MagicMock(mobile_dataswitch=mobile_dataswitch) + lan = MagicMock(host_info=lan_host_info) + wlan = MagicMock( + multi_basic_settings=multi_basic_settings, + wifi_feature_switch=wifi_feature_switch, + host_list=wlan_host_list, + ) + + return MagicMock( + device=device, + monitoring=monitoring, + net=net, + sms=sms, + dial_up=dial_up, + lan=lan, + wlan=wlan, + ) diff --git a/tests/components/huawei_lte/snapshots/test_diagnostics.ambr b/tests/components/huawei_lte/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..0c2076d9c63 --- /dev/null +++ b/tests/components/huawei_lte/snapshots/test_diagnostics.ambr @@ -0,0 +1,201 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'mac': '**REDACTED**', + 'url': 'http://huawei-lte.example.com', + }), + 'router': dict({ + 'device_information': dict({ + 'Classify': 'cpe', + 'DeviceName': 'Test Router', + 'HardwareVersion': '1.0.0', + 'Iccid': '**REDACTED**', + 'Imei': '**REDACTED**', + 'ImeiSvn': '01', + 'Imsi': '**REDACTED**', + 'MacAddress1': '**REDACTED**', + 'MacAddress2': None, + 'Mccmnc': '**REDACTED**', + 'Msisdn': None, + 'ProductFamily': 'LTE', + 'SerialNumber': '**REDACTED**', + 'SoftwareVersion': '2.0.0', + 'WanIPAddress': '**REDACTED**', + 'WanIPv6Address': '**REDACTED**', + 'WebUIVersion': '3.0.0', + 'WifiMacAddrWl0': '**REDACTED**', + 'WifiMacAddrWl1': '**REDACTED**', + 'iniversion': 'test-ini-version', + 'spreadname_en': 'Huawei 4G Router N123', + 'spreadname_zh': '华为4G路由 N123', + 'submask': '255.255.255.255', + 'supportmode': 'LTE|WCDMA|GSM', + 'uptime': '4242424', + 'wan_dns_address': '**REDACTED**', + 'wan_ipv6_dns_address': '**REDACTED**', + 'workmode': 'LTE', + }), + 'device_signal': dict({ + 'arfcn': None, + 'band': '1', + 'bsic': None, + 'cell_id': '**REDACTED**', + 'cqi0': '11', + 'cqi1': '5', + 'dl_mcs': 'mcsDownCarrier1Code0:8 mcsDownCarrier1Code1:9', + 'dlbandwidth': '20MHz', + 'dlfrequency': '2159700kHz', + 'earfcn': 'DL:123 UL:45678', + 'ecio': None, + 'enodeb_id': '**REDACTED**', + 'ims': '0', + 'lac': None, + 'ltedlfreq': '21597', + 'lteulfreq': '19697', + 'mode': '7', + 'nei_cellid': '**REDACTED**', + 'pci': '**REDACTED**', + 'plmn': '**REDACTED**', + 'rac': None, + 'rrc_status': '1', + 'rscp': None, + 'rsrp': '-100dBm', + 'rsrq': '-10.0dB', + 'rssi': '-70dBm', + 'rxlev': None, + 'sc': None, + 'sinr': '10dB', + 'tac': '**REDACTED**', + 'tdd': None, + 'transmode': 'TM[4]', + 'txpower': 'PPusch:-1dBm PPucch:-11dBm PSrs:10dBm PPrach:0dBm', + 'ul_mcs': 'mcsUpCarrier1:20', + 'ulbandwidth': '20MHz', + 'ulfrequency': '1969700kHz', + 'wdlfreq': None, + }), + 'dialup_mobile_dataswitch': dict({ + 'dataswitch': '1', + }), + 'lan_host_info': '**REDACTED**', + 'monitoring_check_notifications': dict({ + 'OnlineUpdateStatus': '42', + 'SimOperEvent': '0', + 'SmsStorageFull': '0', + 'UnreadMessage': '2', + }), + 'monitoring_month_statistics': dict({ + 'CurrentDayDuration': '10000', + 'CurrentDayUsed': '123456789', + 'CurrentMonthDownload': '1000000000', + 'CurrentMonthUpload': '500000000', + 'MonthDuration': '720000', + 'MonthLastClearTime': '2025-07-01', + }), + 'monitoring_status': dict({ + 'BatteryLevel': None, + 'BatteryPercent': None, + 'BatteryStatus': None, + 'ConnectionStatus': '901', + 'CurrentNetworkType': '19', + 'CurrentNetworkTypeEx': '101', + 'CurrentServiceDomain': '3', + 'CurrentWifiUser': '42', + 'PrimaryDns': '**REDACTED**', + 'PrimaryIPv6Dns': '**REDACTED**', + 'RoamingStatus': '0', + 'SecondaryDns': '**REDACTED**', + 'SecondaryIPv6Dns': '**REDACTED**', + 'ServiceStatus': '2', + 'SignalIcon': '5', + 'SignalStrength': None, + 'SimStatus': '1', + 'TotalWifiUser': '64', + 'WifiConnectionStatus': None, + 'WifiStatus': '1', + 'WifiStatusExCustom': '0', + 'cellroam': '1', + 'classify': 'cpe', + 'currenttotalwifiuser': '0', + 'flymode': '0', + 'hvdcp_online': '0', + 'maxsignal': '5', + 'simlockStatus': '0', + 'usbup': '0', + 'wififrequence': '1', + 'wifiindooronly': '0', + 'wifiswitchstatus': '1', + }), + 'monitoring_traffic_statistics': dict({ + 'CurrentConnectTime': '123456', + 'CurrentDownload': '5000000000', + 'CurrentDownloadRate': '700', + 'CurrentUpload': '2000000000', + 'CurrentUploadRate': '600', + 'TotalConnectTime': '1234567', + 'TotalDownload': '50000000000', + 'TotalUpload': '20000000000', + 'showtraffic': '1', + }), + 'net_current_plmn': '**REDACTED**', + 'net_net_mode': dict({ + 'LTEBand': '7FFFFFFFFFFFFFFF', + 'NetworkBand': '3FFFFFFF', + 'NetworkMode': '03', + }), + 'sms_sms_count': dict({ + 'LocalDeleted': '0', + 'LocalDraft': '1', + 'LocalInbox': '5', + 'LocalMax': '500', + 'LocalOutbox': '2', + 'LocalUnread': '0', + 'NewMsg': '0', + 'SimDraft': '0', + 'SimInbox': '0', + 'SimMax': '30', + 'SimOutbox': '0', + 'SimUnread': '0', + 'SimUsed': '0', + }), + 'wlan_wifi_feature_switch': dict({ + 'acmode_enable': '1', + 'chinesessid_enable': '0', + 'doubleap5g_enable': '0', + 'guestwifi_enable': '0', + 'guidefrequencyenable': '0', + 'hilink_dbho_enable': '1', + 'isdoublechip': '1', + 'maxapnum': '4', + 'maxassocoffloadon': None, + 'oledshowpassword': '1', + 'opennonewps_enable': '1', + 'paraimmediatework_enable': '1', + 'pmf_enable': '1', + 'show_maxassoc': '0', + 'showssid_enable': '0', + 'support_trigger_dualband_wps': '1', + 'wifi24g_switch_enable': '1', + 'wifi5g_enabled': '1', + 'wifi5gnamepostfix': '_5G', + 'wifi_chip_maxassoc': '32', + 'wifi_country_enable': '0', + 'wifi_dbdc_enable': '0', + 'wifi_dfs_enable': '0', + 'wifiautocountry_enabled': '0', + 'wifiguesttimeextendenable': '1', + 'wifimacfilterextendenable': '1', + 'wifimaxmacfilternum': '32', + 'wifishowradioswitch': '3', + 'wifispecialcharenable': '1', + 'wifiwpsmode': '0', + 'wifiwpssuportwepnone': '0', + 'wps_cancel_enable': '1', + 'wps_switch_enable': '1', + }), + 'wlan_wifi_guest_network_switch': dict({ + }), + }), + }) +# --- diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index f75b0e7f2b0..e40a3ca5a01 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -13,7 +13,11 @@ import requests_mock from requests_mock import ANY from homeassistant import config_entries -from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN +from homeassistant.components.huawei_lte.const import ( + CONF_UNAUTHENTICATED_MODE, + CONF_UPNP_UDN, + DOMAIN, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -330,24 +334,25 @@ async def test_ssdp( url = FIXTURE_USER_INPUT[CONF_URL][:-1] # strip trailing slash for appending port context = {"source": config_entries.SOURCE_SSDP} login_requests_mock.request(**requests_mock_request_kwargs) + service_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location=f"{url}:60957/rootDesc.xml", + upnp={ + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + ATTR_UPNP_MANUFACTURER: "Huawei", + ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", + ATTR_UPNP_MODEL_NAME: "Huawei router", + ATTR_UPNP_MODEL_NUMBER: "12345678", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + **upnp_data, + }, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context=context, - data=SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="upnp:rootdevice", - ssdp_location=f"{url}:60957/rootDesc.xml", - upnp={ - ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ATTR_UPNP_MANUFACTURER: "Huawei", - ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", - ATTR_UPNP_MODEL_NAME: "Huawei router", - ATTR_UPNP_MODEL_NUMBER: "12345678", - ATTR_UPNP_PRESENTATION_URL: url, - ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", - **upnp_data, - }, - ), + data=service_info, ) for k, v in expected_result.items(): @@ -356,6 +361,24 @@ async def test_ssdp( assert result["data_schema"] is not None assert result["data_schema"]({})[CONF_URL] == url + "/" + if result["type"] == FlowResultType.ABORT: + return + + login_requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + text="OK", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == service_info.upnp[ATTR_UPNP_MODEL_NAME] + assert result["result"].data[CONF_UPNP_UDN] == service_info.upnp[ATTR_UPNP_UDN] + @pytest.mark.parametrize( ("login_response_text", "expected_result", "expected_entry_data"), diff --git a/tests/components/huawei_lte/test_diagnostics.py b/tests/components/huawei_lte/test_diagnostics.py new file mode 100644 index 00000000000..e63ba94e9be --- /dev/null +++ b/tests/components/huawei_lte/test_diagnostics.py @@ -0,0 +1,38 @@ +"""Test huawei_lte diagnostics.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client_full + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_entry_diagnostics( + client, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + client.return_value = magic_client_full() + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, huawei_lte) + + assert result == snapshot(exclude=props("entry_id", "created_at", "modified_at")) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index 807996f1093..5f287b1d8e3 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -11,7 +11,11 @@ from homeassistant.components.light import ColorMode from homeassistant.config_entries import ConfigEntryState 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 ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.util import color as color_util from .conftest import create_config_entry @@ -776,6 +780,7 @@ def test_hs_color() -> None: async def test_group_features( hass: HomeAssistant, + area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, mock_bridge_v1: Mock, @@ -966,16 +971,22 @@ async def test_group_features( entry = entity_registry.async_get("light.hue_lamp_1") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area is None + assert device_entry.area_id is None entry = entity_registry.async_get("light.hue_lamp_2") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area == "Living Room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living Room").id + ) entry = entity_registry.async_get("light.hue_lamp_3") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area == "Living Room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living Room").id + ) entry = entity_registry.async_get("light.hue_lamp_4") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area == "Dining Room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Dining Room").id + ) diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index ec8406bfe7b..55a2c687867 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -363,6 +363,7 @@ async def test_if_state_legacy( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], @@ -376,6 +377,7 @@ async def test_if_state_legacy( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], @@ -417,6 +419,7 @@ async def test_if_state_legacy( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], @@ -430,6 +433,7 @@ async def test_if_state_legacy( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], @@ -533,6 +537,7 @@ async def test_capabilities( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], @@ -546,6 +551,7 @@ async def test_capabilities( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], @@ -587,6 +593,7 @@ async def test_capabilities( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], @@ -600,6 +607,7 @@ async def test_capabilities( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", } ], diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index e1b2b2bff61..6d45861b227 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -543,7 +543,14 @@ async def test_get_trigger_capabilities_on(hass: HomeAssistant) -> None: assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ) == [{"name": "for", "optional": True, "type": "positive_time_period_dict"}] + ) == [ + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } + ] async def test_get_trigger_capabilities_off(hass: HomeAssistant) -> None: @@ -563,7 +570,14 @@ async def test_get_trigger_capabilities_off(hass: HomeAssistant) -> None: assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ) == [{"name": "for", "optional": True, "type": "positive_time_period_dict"}] + ) == [ + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } + ] async def test_get_trigger_capabilities_humidity(hass: HomeAssistant) -> None: @@ -588,13 +602,20 @@ async def test_get_trigger_capabilities_humidity(hass: HomeAssistant) -> None: "description": {"suffix": "%"}, "name": "above", "optional": True, + "required": False, "type": "integer", }, { "description": {"suffix": "%"}, "name": "below", "optional": True, + "required": False, "type": "integer", }, - {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + }, ] diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 06e11ec1252..73f9c5e2aaa 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -86,7 +86,8 @@ "override": { "action": "NOT_ACTIVE" }, - "restrictedReason": "WEEK_SCHEDULE" + "restrictedReason": "WEEK_SCHEDULE", + "externalReason": 4000 }, "metadata": { "connected": true, diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index 3d48125aa9a..058fc214a91 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -47,6 +47,54 @@ 'state': 'unavailable', }) # --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset cutting blade usage time', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_cutting_blade_usage_time', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_reset_cutting_blade_usage_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Reset cutting blade usage time', + }), + 'context': , + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_snapshot[button.test_mower_1_sync_clock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr index 7cd8c68b624..7ff32f69df0 100644 --- a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr @@ -6,72 +6,72 @@ dict({ 'end': '2023-06-05T09:00:00+02:00', 'start': '2023-06-05T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-06T00:00:00+02:00', 'start': '2023-06-05T19:00:00+02:00', - 'summary': 'Front lawn schedule 1', + 'summary': 'Test Mower 1 Front lawn schedule 1', }), dict({ 'end': '2023-06-06T08:00:00+02:00', 'start': '2023-06-06T00:00:00+02:00', - 'summary': 'Back lawn schedule 1', + 'summary': 'Test Mower 1 Back lawn schedule 1', }), dict({ 'end': '2023-06-06T08:00:00+02:00', 'start': '2023-06-06T00:00:00+02:00', - 'summary': 'Front lawn schedule 2', + 'summary': 'Test Mower 1 Front lawn schedule 2', }), dict({ 'end': '2023-06-06T09:00:00+02:00', 'start': '2023-06-06T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-08T00:00:00+02:00', 'start': '2023-06-07T19:00:00+02:00', - 'summary': 'Front lawn schedule 1', + 'summary': 'Test Mower 1 Front lawn schedule 1', }), dict({ 'end': '2023-06-08T08:00:00+02:00', 'start': '2023-06-08T00:00:00+02:00', - 'summary': 'Back lawn schedule 1', + 'summary': 'Test Mower 1 Back lawn schedule 1', }), dict({ 'end': '2023-06-08T08:00:00+02:00', 'start': '2023-06-08T00:00:00+02:00', - 'summary': 'Front lawn schedule 2', + 'summary': 'Test Mower 1 Front lawn schedule 2', }), dict({ 'end': '2023-06-08T09:00:00+02:00', 'start': '2023-06-08T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-10T00:00:00+02:00', 'start': '2023-06-09T19:00:00+02:00', - 'summary': 'Front lawn schedule 1', + 'summary': 'Test Mower 1 Front lawn schedule 1', }), dict({ 'end': '2023-06-10T08:00:00+02:00', 'start': '2023-06-10T00:00:00+02:00', - 'summary': 'Back lawn schedule 1', + 'summary': 'Test Mower 1 Back lawn schedule 1', }), dict({ 'end': '2023-06-10T08:00:00+02:00', 'start': '2023-06-10T00:00:00+02:00', - 'summary': 'Front lawn schedule 2', + 'summary': 'Test Mower 1 Front lawn schedule 2', }), dict({ 'end': '2023-06-10T09:00:00+02:00', 'start': '2023-06-10T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-12T09:00:00+02:00', 'start': '2023-06-12T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), ]), }), @@ -80,7 +80,7 @@ dict({ 'end': '2023-06-05T02:49:00+02:00', 'start': '2023-06-05T02:00:00+02:00', - 'summary': 'Schedule 1', + 'summary': 'Test Mower 2 Schedule 1', }), ]), }), diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index d5546b0d2af..170fbe7ad82 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -80,6 +80,7 @@ 'work_area_name': 'Front lawn', }), 'planner': dict({ + 'external_reason': 'ifttt', 'next_start_datetime': '2023-06-05T19:00:00+02:00', 'override': dict({ 'action': 'not_active', diff --git a/tests/components/husqvarna_automower/snapshots/test_event.ambr b/tests/components/husqvarna_automower/snapshots/test_event.ambr new file mode 100644 index 00000000000..e01f8d04f2c --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_event.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_event_snapshot[event.test_mower_1_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_mower_1_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Message', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'message', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.test_mower_1_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date_time': HAFakeDatetime(2025, 7, 13, 15, 30, tzinfo=datetime.timezone.utc), + 'event_type': 'wheel_motor_overloaded_rear_left', + 'event_types': list([ + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + 'friendly_name': 'Test Mower 1 Message', + 'latitude': 49.0, + 'longitude': 10.0, + 'severity': , + }), + 'context': , + 'entity_id': 'event.test_mower_1_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-05T12:00:00.000+00:00', + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 1428a75d7b4..82116391f4f 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'c7233734-b219-4287-a173-08e3643f89f0', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Husqvarna', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123', - 'suggested_area': 'Garden', 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 109e6614545..6628113d8c3 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -205,10 +205,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -219,6 +219,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -255,6 +258,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -268,6 +272,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -283,6 +288,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -300,13 +307,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'config_entry_id': , @@ -372,10 +372,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -386,6 +386,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -422,6 +425,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -435,6 +439,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -450,6 +455,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -467,13 +474,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'context': , @@ -585,6 +585,66 @@ 'state': '40', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inactive reason', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inactive_reason', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_inactive_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 Inactive reason', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -918,6 +978,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -965,6 +1037,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , @@ -1484,10 +1568,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -1498,6 +1582,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -1534,6 +1621,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -1547,6 +1635,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -1562,6 +1651,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -1579,13 +1670,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'config_entry_id': , @@ -1651,10 +1735,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -1665,6 +1749,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -1701,6 +1788,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -1714,6 +1802,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -1729,6 +1818,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -1746,13 +1837,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'context': , @@ -1893,6 +1977,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -1940,6 +2036,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 9fb5ad28c89..dcb4252ac8e 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat @pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC)) -async def test_button_states_and_commands( +async def test_button_error_confirm( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -58,42 +58,43 @@ async def test_button_states_and_commands( state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == "2023-06-05T00:16:00+00:00" - mock_automower_client.commands.error_confirm.side_effect = ApiError("Test error") - with pytest.raises( - HomeAssistantError, - match="Failed to send command: Test error", - ): - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - +@pytest.mark.parametrize( + ("entity_id", "name", "expected_command"), + [ + ( + "button.test_mower_1_confirm_error", + "Test Mower 1 Confirm error", + "error_confirm", + ), + ( + "button.test_mower_1_sync_clock", + "Test Mower 1 Sync clock", + "set_datetime", + ), + ( + "button.test_mower_1_reset_cutting_blade_usage_time", + "Test Mower 1 Reset cutting blade usage time", + "reset_cutting_blade_usage_time", + ), + ], +) @pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) -async def test_sync_clock( +async def test_button_commands( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], + entity_id: str, + name: str, + expected_command: str, ) -> None: - """Test sync clock button command.""" - entity_id = "button.test_mower_1_sync_clock" + """Test Automower button commands.""" + values[TEST_MOWER_ID].mower.is_error_confirmable = True await setup_integration(hass, mock_config_entry) + state = hass.states.get(entity_id) - assert state.name == "Test Mower 1 Sync clock" + assert state.name == name mock_automower_client.get_status.return_value = values @@ -103,11 +104,15 @@ async def test_sync_clock( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID) + + command_mock = getattr(mock_automower_client.commands, expected_command) + command_mock.assert_called_once_with(TEST_MOWER_ID) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" - mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error") + command_mock.reset_mock() + command_mock.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_event.py b/tests/components/husqvarna_automower/test_event.py new file mode 100644 index 00000000000..6cbfa102976 --- /dev/null +++ b/tests/components/husqvarna_automower/test_event.py @@ -0,0 +1,206 @@ +"""Tests for init module.""" + +from collections.abc import Callable +from copy import deepcopy +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from aioautomower.model import MowerAttributes, SingleMessageData +from aioautomower.model.model_message import Message, Severity, SingleMessageAttributes +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import setup_integration +from .const import TEST_MOWER_ID + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.freeze_time(datetime(2023, 6, 5, 12)) +async def test_event( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], +) -> None: + """Test that a new message arriving over the websocket creates and updates the sensor.""" + callbacks: list[Callable[[SingleMessageData], None]] = [] + + @callback + def fake_register_websocket_response( + cb: Callable[[SingleMessageData], None], + ) -> None: + callbacks.append(cb) + + mock_automower_client.register_single_message_callback.side_effect = ( + fake_register_websocket_response + ) + + # Set up integration + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Ensure callback was registered for the test mower + assert mock_automower_client.register_single_message_callback.called + + # Check initial state (event entity not available yet) + state = hass.states.get("event.test_mower_1_message") + assert state is None + + # Simulate a new message for this mower and check entity creation + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 15, 30, tzinfo=UTC), + code="wheel_motor_overloaded_rear_left", + severity=Severity.ERROR, + latitude=49.0, + longitude=10.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" + + # Reload the config entry to ensure the entity is created again + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" + + # Check updating event with a new message + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 16, 00, tzinfo=UTC), + code="alarm_mower_lifted", + severity=Severity.ERROR, + latitude=48.0, + longitude=11.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" + + # Check message for another mower, creates an new entity and dont + # change the state of the first entity + message = SingleMessageData( + type="messages", + id="1234", + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 16, 00, tzinfo=UTC), + code="battery_problem", + severity=Severity.ERROR, + latitude=48.0, + longitude=11.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + entry = entity_registry.async_get("event.test_mower_1_message") + assert entry is not None + assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" + state = hass.states.get("event.test_mower_2_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "battery_problem" + + # Check event entity is removed, when the mower is removed + values_copy = deepcopy(values) + values_copy.pop("1234") + mock_automower_client.get_status.return_value = values_copy + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_2_message") + assert state is None + entry = entity_registry.async_get("event.test_mower_2_message") + assert entry is None + + +@pytest.mark.freeze_time(datetime(2023, 6, 5, 12)) +async def test_event_snapshot( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a new message arriving over the websocket updates the sensor.""" + with patch( + "homeassistant.components.husqvarna_automower.PLATFORMS", + [Platform.EVENT], + ): + callbacks: list[Callable[[SingleMessageData], None]] = [] + + @callback + def fake_register_websocket_response( + cb: Callable[[SingleMessageData], None], + ) -> None: + callbacks.append(cb) + + mock_automower_client.register_single_message_callback.side_effect = ( + fake_register_websocket_response + ) + + # Set up integration + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Ensure callback was registered for the test mower + assert mock_automower_client.register_single_message_callback.called + + # Simulate a new message for this mower + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 15, 30, tzinfo=UTC), + code="wheel_motor_overloaded_rear_left", + severity=Severity.ERROR, + latitude=49.0, + longitude=10.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ecb92bb39cf..a157380ab3c 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -1,7 +1,9 @@ """Tests for init module.""" from asyncio import Event -from datetime import datetime +from collections.abc import Callable +from copy import deepcopy +from datetime import datetime, time as dt_time, timedelta import http import time from unittest.mock import AsyncMock, patch @@ -12,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerAttributes, WorkArea +from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -20,7 +22,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.const import DOMAIN, OAUTH2_TOKEN from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState -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.util import dt as dt_util @@ -221,6 +223,73 @@ async def test_device_info( assert reg_device == snapshot +async def test_constant_polling( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + values: dict[str, MowerAttributes], + freezer: FrozenDateTimeFactory, +) -> None: + """Verify that receiving a WebSocket update does not interrupt the regular polling cycle. + + The test simulates a WebSocket update that changes an entity's state, then advances time + to trigger a scheduled poll to confirm polled data also arrives. + """ + test_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + assert mock_automower_client.register_data_callback.called + assert "cb" in callback_holder + + state = hass.states.get("sensor.test_mower_1_battery") + assert state is not None + assert state.state == "100" + state = hass.states.get("sensor.test_mower_1_front_lawn_progress") + assert state is not None + assert state.state == "40" + + test_values[TEST_MOWER_ID].battery.battery_percent = 77 + + freezer.tick(SCAN_INTERVAL - timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + callback_holder["cb"](test_values) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_mower_1_battery") + assert state is not None + assert state.state == "77" + state = hass.states.get("sensor.test_mower_1_front_lawn_progress") + assert state is not None + assert state.state == "40" + + test_values[TEST_MOWER_ID].work_areas[123456].progress = 50 + mock_automower_client.get_status.return_value = test_values + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_automower_client.get_status.assert_awaited() + state = hass.states.get("sensor.test_mower_1_battery") + assert state is not None + assert state.state == "77" + state = hass.states.get("sensor.test_mower_1_front_lawn_progress") + assert state is not None + assert state.state == "50" + + async def test_coordinator_automatic_registry_cleanup( hass: HomeAssistant, mock_automower_client: AsyncMock, @@ -243,8 +312,9 @@ async def test_coordinator_automatic_registry_cleanup( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) # Remove mower 2 and check if it worked - mower2 = values.pop("1234") - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower2 = values_copy.pop("1234") + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -258,8 +328,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 2 and check if it worked - values["1234"] = mower2 - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + values_copy["1234"] = mower2 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -273,8 +344,9 @@ async def test_coordinator_automatic_registry_cleanup( ) # Remove mower 1 and check if it worked - mower1 = values.pop(TEST_MOWER_ID) - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower1 = values_copy.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -288,11 +360,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 1 and check if it worked - values[TEST_MOWER_ID] = mower1 - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + values_copy = deepcopy(values) + values_copy[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -315,14 +385,45 @@ async def test_add_and_remove_work_area( values: dict[str, MowerAttributes], ) -> None: """Test adding a work area in runtime.""" + websocket_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] current_entites_start = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) - values[TEST_MOWER_ID].work_area_names.append("new work area") - values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) - values[TEST_MOWER_ID].work_areas.update( + await hass.async_block_till_done() + + assert mock_automower_client.register_data_callback.called + assert "cb" in callback_holder + + new_task = Calendar( + start=dt_time(hour=11), + duration=timedelta(60), + monday=True, + tuesday=True, + wednesday=True, + thursday=True, + friday=True, + saturday=True, + sunday=True, + work_area_id=1, + ) + websocket_values[TEST_MOWER_ID].calendar.tasks.append(new_task) + poll_values = deepcopy(websocket_values) + poll_values[TEST_MOWER_ID].work_area_names.append("new work area") + poll_values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) + poll_values[TEST_MOWER_ID].work_areas.update( { 1: WorkArea( name="new work area", @@ -335,10 +436,15 @@ async def test_add_and_remove_work_area( ) } ) - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) + mock_automower_client.get_status.return_value = poll_values + + callback_holder["cb"](websocket_values) await hass.async_block_till_done() + assert mock_automower_client.get_status.called + + state = hass.states.get("sensor.test_mower_1_new_work_area_progress") + assert state is not None + assert state.state == "12" current_entites_after_addition = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) @@ -350,15 +456,21 @@ async def test_add_and_remove_work_area( + ADDITIONAL_SWITCH_ENTITIES ) - values[TEST_MOWER_ID].work_area_names.remove("new work area") - del values[TEST_MOWER_ID].work_area_dict[1] - del values[TEST_MOWER_ID].work_areas[1] - values[TEST_MOWER_ID].work_area_names.remove("Front lawn") - del values[TEST_MOWER_ID].work_area_dict[123456] - del values[TEST_MOWER_ID].work_areas[123456] - del values[TEST_MOWER_ID].calendar.tasks[:2] - values[TEST_MOWER_ID].mower.work_area_id = 654321 - mock_automower_client.get_status.return_value = values + poll_values[TEST_MOWER_ID].work_area_names.remove("new work area") + del poll_values[TEST_MOWER_ID].work_area_dict[1] + del poll_values[TEST_MOWER_ID].work_areas[1] + poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn") + del poll_values[TEST_MOWER_ID].work_area_dict[123456] + del poll_values[TEST_MOWER_ID].work_areas[123456] + + poll_values[TEST_MOWER_ID].calendar.tasks = [ + task + for task in poll_values[TEST_MOWER_ID].calendar.tasks + if task.work_area_id not in [1, 123456] + ] + + poll_values[TEST_MOWER_ID].mower.work_area_id = 654321 + mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -372,3 +484,212 @@ async def test_add_and_remove_work_area( - ADDITIONAL_NUMBER_ENTITIES - ADDITIONAL_SENSOR_ENTITIES ) + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_dynamic_polling( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + websocket_values = deepcopy(values) + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # websocket is still active, and mowers are active -> polling required + mock_automower_client.get_status.reset_mock() + assert mock_automower_client.get_status.call_count == 0 + poll_values[TEST_MOWER_ID].metadata.connected = True + poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED + poll_values["1234"].metadata.connected = False + poll_values["1234"].mower.state = MowerStates.OFF + websocket_values = deepcopy(poll_values) + callback_holder["data_cb"](websocket_values) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_websocket_watchdog( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # Simulate Pong loss and reset mock -> polling required + mock_automower_client.send_empty_message.return_value = False + mock_automower_client.get_status.reset_mock() + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 0 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index c62cf6653c4..bf888779baa 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -42,6 +42,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed MowerStates.IN_OPERATION, LawnMowerActivity.DOCKED, ), + ( + MowerActivities.GOING_HOME, + MowerStates.RESTRICTED, + LawnMowerActivity.RETURNING, + ), ], ) async def test_lawn_mower_states( diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index b1029f5919b..204fba872c4 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -4,13 +4,19 @@ import datetime from unittest.mock import AsyncMock, patch import zoneinfo -from aioautomower.model import MowerAttributes, MowerModes, MowerStates +from aioautomower.model import ( + ExternalReasons, + MowerAttributes, + MowerModes, + MowerStates, + RestrictedReasons, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -39,7 +45,7 @@ async def test_sensor_unknown_states( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_mode") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_cutting_blade_usage_time_sensor( @@ -78,7 +84,7 @@ async def test_next_start_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_next_start") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_work_area_sensor( @@ -123,6 +129,41 @@ async def test_work_area_sensor( assert state.state == "no_work_area_active" +async def test_restricted_reason_sensor( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], +) -> None: + """Test the work area sensor.""" + sensor = "sensor.test_mower_1_restricted_reason" + await setup_integration(hass, mock_config_entry) + state = hass.states.get(sensor) + assert state is not None + assert state.state == RestrictedReasons.WEEK_SCHEDULE + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[TEST_MOWER_ID].planner.external_reason = None + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == RestrictedReasons.EXTERNAL + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[ + TEST_MOWER_ID + ].planner.external_reason = ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("sensor_to_test"), diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index b7aa14ef0bf..6b4ab8236f9 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '00000000-0000-0000-0000-000000000003_1197489078', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Husqvarna', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8f2bfadf56a --- /dev/null +++ b/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_setup[sensor.husqvarna_automower_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.husqvarna_automower_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'husqvarna_automower_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000003_1197489078_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[sensor.husqvarna_automower_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Husqvarna AutoMower Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.husqvarna_automower_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/husqvarna_automower_ble/test_sensor.py b/tests/components/husqvarna_automower_ble/test_sensor.py new file mode 100644 index 00000000000..d1f0a13cc43 --- /dev/null +++ b/tests/components/husqvarna_automower_ble/test_sensor.py @@ -0,0 +1,32 @@ +"""Test the Husqvarna Automower Bluetooth setup.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.usefixtures("mock_automower_client") + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup creates expected entities.""" + + with patch( + "homeassistant.components.husqvarna_automower_ble.PLATFORMS", [Platform.SENSOR] + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/huum/__init__.py b/tests/components/huum/__init__.py index 443cbd52c36..d280bab6a59 100644 --- a/tests/components/huum/__init__.py +++ b/tests/components/huum/__init__.py @@ -1 +1,18 @@ """Tests for the huum integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the Huum integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.huum.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py new file mode 100644 index 00000000000..8342603a30d --- /dev/null +++ b/tests/components/huum/conftest.py @@ -0,0 +1,78 @@ +"""Configuration for Huum tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from huum.const import SaunaStatus +import pytest + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_huum() -> Generator[AsyncMock]: + """Mock data from the API.""" + huum = AsyncMock() + with ( + patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.turn_on", + return_value=huum, + ) as turn_on, + patch( + "homeassistant.components.huum.coordinator.Huum.toggle_light", + return_value=huum, + ) as toggle_light, + ): + huum.status = SaunaStatus.ONLINE_NOT_HEATING + huum.config = 3 + huum.door_closed = True + huum.temperature = 30 + huum.sauna_name = 123456 + huum.target_temperature = 80 + huum.light = 1 + huum.humidity = 5 + huum.sauna_config.child_lock = "OFF" + huum.sauna_config.max_heating_time = 3 + huum.sauna_config.min_heating_time = 0 + huum.sauna_config.max_temp = 110 + huum.sauna_config.min_temp = 40 + huum.sauna_config.max_timer = 0 + huum.sauna_config.min_timer = 0 + huum.turn_on = turn_on + huum.toggle_light = toggle_light + + yield huum + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.huum.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "huum@sauna.org", + CONF_PASSWORD: "ukuuku", + }, + unique_id="123456", + entry_id="AABBCC112233", + ) diff --git a/tests/components/huum/snapshots/test_binary_sensor.ambr b/tests/components/huum/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..3490ff594b6 --- /dev/null +++ b/tests/components/huum/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.huum_sauna_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.huum_sauna_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC112233_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.huum_sauna_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Huum sauna Door', + }), + 'context': , + 'entity_id': 'binary_sensor.huum_sauna_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/snapshots/test_climate.ambr b/tests/components/huum/snapshots/test_climate.ambr new file mode 100644 index 00000000000..f18fd279f25 --- /dev/null +++ b/tests/components/huum/snapshots/test_climate.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_climate_entity[climate.huum_sauna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 110, + 'min_temp': 40, + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.huum_sauna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator-off', + 'original_name': None, + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entity[climate.huum_sauna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Huum sauna', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator-off', + 'max_temp': 110, + 'min_temp': 40, + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 80, + }), + 'context': , + 'entity_id': 'climate.huum_sauna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/snapshots/test_light.ambr b/tests/components/huum/snapshots/test_light.ambr new file mode 100644 index 00000000000..da449c16fe8 --- /dev/null +++ b/tests/components/huum/snapshots/test_light.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_light[light.huum_sauna_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.huum_sauna_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_light[light.huum_sauna_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Huum sauna Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.huum_sauna_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/huum/snapshots/test_number.ambr b/tests/components/huum/snapshots/test_number.ambr new file mode 100644 index 00000000000..19c0642f007 --- /dev/null +++ b/tests/components/huum/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_number_entity[number.huum_sauna_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.huum_sauna_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entity[number.huum_sauna_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Huum sauna Humidity', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.huum_sauna_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- diff --git a/tests/components/huum/test_binary_sensor.py b/tests/components/huum/test_binary_sensor.py new file mode 100644 index 00000000000..5ea2ae69a11 --- /dev/null +++ b/tests/components/huum/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "binary_sensor.huum_sauna_door" + + +async def test_binary_sensor( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.BINARY_SENSOR] + ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py new file mode 100644 index 00000000000..ca7fcf81185 --- /dev/null +++ b/tests/components/huum/test_climate.py @@ -0,0 +1,78 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from huum.const import SaunaStatus +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "climate.huum_sauna" + + +async def test_climate_entity( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + + mock_huum.turn_on.assert_called_once() + + +async def test_set_temperature( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 60, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_called_once_with(60) diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py index 9917f71fc08..d59eac51207 100644 --- a/tests/components/huum/test_config_flow.py +++ b/tests/components/huum/test_config_flow.py @@ -1,6 +1,6 @@ """Test the huum config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from huum.exceptions import Forbidden import pytest @@ -13,11 +13,13 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -TEST_USERNAME = "test-username" -TEST_PASSWORD = "test-password" +TEST_USERNAME = "huum@sauna.org" +TEST_PASSWORD = "ukuuku" -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, mock_huum: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -26,24 +28,14 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_USERNAME @@ -54,42 +46,28 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: +async def test_signup_flow_already_set_up( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test that we handle already existing entities with same id.""" - mock_config_entry = MockConfigEntry( - title="Huum Sauna", - domain=DOMAIN, - unique_id=TEST_USERNAME, - data={ - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT @pytest.mark.parametrize( @@ -103,7 +81,11 @@ async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: ], ) async def test_huum_errors( - hass: HomeAssistant, raises: Exception, error_base: str + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + raises: Exception, + error_base: str, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -125,21 +107,11 @@ async def test_huum_errors( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error_base} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/huum/test_init.py b/tests/components/huum/test_init.py new file mode 100644 index 00000000000..fac5fa875ee --- /dev/null +++ b/tests/components/huum/test_init.py @@ -0,0 +1,27 @@ +"""Tests for the Huum __init__.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry + + +async def test_loading_and_unloading_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_huum: AsyncMock +) -> None: + """Test loading and unloading a config entry.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/huum/test_light.py b/tests/components/huum/test_light.py new file mode 100644 index 00000000000..8ad12a36f4e --- /dev/null +++ b/tests/components/huum/test_light.py @@ -0,0 +1,76 @@ +"""Tests for the Huum light entity.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "light.huum_sauna_light" + + +async def test_light( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off light.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + Platform.LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_huum.toggle_light.assert_called_once() + + +async def test_light_turn_on( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning on light.""" + mock_huum.light = 0 + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + Platform.LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_huum.toggle_light.assert_called_once() diff --git a/tests/components/huum/test_number.py b/tests/components/huum/test_number.py new file mode 100644 index 00000000000..3d7a74bfce3 --- /dev/null +++ b/tests/components/huum/test_number.py @@ -0,0 +1,77 @@ +"""Tests for the Huum number entity.""" + +from unittest.mock import AsyncMock + +from huum.const import SaunaStatus +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "number.huum_sauna_humidity" + + +async def test_number_entity( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_humidity( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the humidity.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_VALUE: 5, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_called_once_with(temperature=80, humidity=5) + + +async def test_dont_set_humidity_when_sauna_not_heating( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the humidity.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + + mock_huum.status = SaunaStatus.ONLINE_NOT_HEATING + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_VALUE: 5, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_not_called() diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 8ec3c3da648..31e86589543 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,13 +1,19 @@ """Tests for the Hydrawise integration.""" +from copy import deepcopy from unittest.mock import AsyncMock from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Controller, User, Zone +from homeassistant.components.hydrawise.const import DOMAIN, MAIN_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_connect_retry( @@ -32,3 +38,101 @@ async def test_update_version( # Make sure reauth flow has been initiated assert any(mock_config_entry_legacy.async_get_active_flows(hass, {"reauth"})) + + +async def test_auto_add_devices( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + user: User, + controller: Controller, + zones: list[Zone], + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are auto-added to the device registry.""" + device = device_registry.async_get_device( + identifiers={(DOMAIN, str(controller.id))} + ) + assert device is not None + for zone in zones: + zone_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(zone.id))} + ) + assert zone_device is not None + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + # 1 controller + 2 zones + assert len(all_devices) == 3 + + controller2 = deepcopy(controller) + controller2.id += 10 + controller2.name += " 2" + controller2.sensors = [] + + zones2 = deepcopy(zones) + for zone in zones2: + zone.id += 10 + zone.name += " 2" + + user.controllers = [controller, controller2] + mock_pydrawise.get_zones.side_effect = [zones, zones2] + + # Make the coordinator refresh data. + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + new_controller_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(controller2.id))} + ) + assert new_controller_device is not None + for zone in zones2: + new_zone_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(zone.id))} + ) + assert new_zone_device is not None + + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + # 2 controllers + 4 zones + assert len(all_devices) == 6 + + +async def test_auto_remove_devices( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: MockConfigEntry, + user: User, + controller: Controller, + zones: list[Zone], + freezer: FrozenDateTimeFactory, +) -> None: + """Test old devices are auto-removed from the device registry.""" + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))}) + is not None + ) + for zone in zones: + device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))}) + assert device is not None + + user.controllers = [] + # Make the coordinator refresh data. + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))}) + is None + ) + for zone in zones: + device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))}) + assert device is None + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + assert len(all_devices) == 0 diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 3bcf0df52e3..bb8762f17e2 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -174,10 +174,22 @@ async def test_fetch_image_authenticated( """Test fetching an image with an authenticated client.""" client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 + + resp = await client.head("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 resp = await client.get("/api/image_proxy/image.unknown") assert resp.status == HTTPStatus.NOT_FOUND @@ -260,10 +272,19 @@ async def test_fetch_image_url_success( client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 @respx.mock diff --git a/tests/components/imeon_inverter/conftest.py b/tests/components/imeon_inverter/conftest.py index e147a6ff642..37fa47e7afb 100644 --- a/tests/components/imeon_inverter/conftest.py +++ b/tests/components/imeon_inverter/conftest.py @@ -66,7 +66,7 @@ def mock_imeon_inverter() -> Generator[MagicMock]: "serial": TEST_SERIAL, "url": f"http://{TEST_USER_INPUT[CONF_HOST]}", } - inverter.storage = load_json_object_fixture("sensor_data.json", DOMAIN) + inverter.storage = load_json_object_fixture("entity_data.json", DOMAIN) yield inverter diff --git a/tests/components/imeon_inverter/fixtures/entity_data.json b/tests/components/imeon_inverter/fixtures/entity_data.json new file mode 100644 index 00000000000..a2717f093f4 --- /dev/null +++ b/tests/components/imeon_inverter/fixtures/entity_data.json @@ -0,0 +1,79 @@ +{ + "battery": { + "battery_power": 2500.0, + "battery_soc": 78.0, + "battery_status": "charging", + "battery_stored": 10200.0, + "battery_consumed": 500.0 + }, + "grid": { + "grid_current_l1": 12.5, + "grid_current_l2": 10.8, + "grid_current_l3": 11.2, + "grid_frequency": 50.0, + "grid_voltage_l1": 230.0, + "grid_voltage_l2": 229.5, + "grid_voltage_l3": 230.1 + }, + "input": { + "input_power_l1": 1000.0, + "input_power_l2": 950.0, + "input_power_l3": 980.0, + "input_power_total": 2930.0 + }, + "inverter": { + "inverter_charging_current_limit": 50, + "inverter_injection_power_limit": 5000.0, + "manager_inverter_state": "grid_consumption" + }, + "meter": { + "meter_power": 2000.0 + }, + "output": { + "output_current_l1": 15.0, + "output_current_l2": 14.5, + "output_current_l3": 15.2, + "output_frequency": 49.9, + "output_power_l1": 1100.0, + "output_power_l2": 1080.0, + "output_power_l3": 1120.0, + "output_power_total": 3300.0, + "output_voltage_l1": 231.0, + "output_voltage_l2": 229.8, + "output_voltage_l3": 230.2 + }, + "pv": { + "pv_consumed": 1500.0, + "pv_injected": 800.0, + "pv_power_1": 1200.0, + "pv_power_2": 1300.0, + "pv_power_total": 2500.0 + }, + "temp": { + "temp_air_temperature": 25.0, + "temp_component_temperature": 45.5 + }, + "monitoring": { + "monitoring_self_produced": 2600.0, + "monitoring_self_consumption": 85.0, + "monitoring_self_sufficiency": 90.0 + }, + "monitoring_minute": { + "monitoring_minute_building_consumption": 50.0, + "monitoring_minute_grid_consumption": 8.3, + "monitoring_minute_grid_injection": 11.7, + "monitoring_minute_grid_power_flow": -3.4, + "monitoring_minute_solar_production": 43.3 + }, + "timeline": { + "timeline_type_msg": "info_bat" + }, + "energy": { + "energy_pv": 12000.0, + "energy_grid_injected": 5000.0, + "energy_grid_consumed": 6000.0, + "energy_building_consumption": 15000.0, + "energy_battery_stored": 8000.0, + "energy_battery_consumed": 2000.0 + } +} diff --git a/tests/components/imeon_inverter/fixtures/sensor_data.json b/tests/components/imeon_inverter/fixtures/sensor_data.json deleted file mode 100644 index 566716fe3fa..00000000000 --- a/tests/components/imeon_inverter/fixtures/sensor_data.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "battery": { - "autonomy": 4.5, - "charge_time": 120, - "power": 2500.0, - "soc": 78.0, - "stored": 10.2 - }, - "grid": { - "current_l1": 12.5, - "current_l2": 10.8, - "current_l3": 11.2, - "frequency": 50.0, - "voltage_l1": 230.0, - "voltage_l2": 229.5, - "voltage_l3": 230.1 - }, - "input": { - "power_l1": 1000.0, - "power_l2": 950.0, - "power_l3": 980.0, - "power_total": 2930.0 - }, - "inverter": { - "charging_current_limit": 50, - "injection_power_limit": 5000.0 - }, - "meter": { - "power": 2000.0, - "power_protocol": 2018.0 - }, - "output": { - "current_l1": 15.0, - "current_l2": 14.5, - "current_l3": 15.2, - "frequency": 49.9, - "power_l1": 1100.0, - "power_l2": 1080.0, - "power_l3": 1120.0, - "power_total": 3300.0, - "voltage_l1": 231.0, - "voltage_l2": 229.8, - "voltage_l3": 230.2 - }, - "pv": { - "consumed": 1500.0, - "injected": 800.0, - "power_1": 1200.0, - "power_2": 1300.0, - "power_total": 2500.0 - }, - "temp": { - "air_temperature": 25.0, - "component_temperature": 45.5 - }, - "monitoring": { - "building_consumption": 3000.0, - "economy_factor": 0.8, - "grid_consumption": 500.0, - "grid_injection": 700.0, - "grid_power_flow": -200.0, - "self_consumption": 85.0, - "self_sufficiency": 90.0, - "solar_production": 2600.0 - }, - "monitoring_minute": { - "building_consumption": 50.0, - "grid_consumption": 8.3, - "grid_injection": 11.7, - "grid_power_flow": -3.4, - "solar_production": 43.3 - } -} diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index 8816889f049..673f561d540 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -52,10 +52,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '25.0', + 'state': 'unknown', }) # --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-entry] +# name: test_sensors[sensor.imeon_inverter_battery_consumed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -70,7 +70,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + 'entity_id': 'sensor.imeon_inverter_battery_consumed', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -80,91 +80,35 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery autonomy', + 'original_name': 'Battery consumed', 'platform': 'imeon_inverter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': '111111111111111_battery_autonomy', - 'unit_of_measurement': , + 'translation_key': 'battery_consumed', + 'unique_id': '111111111111111_battery_consumed', + 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-state] +# name: test_sensors[sensor.imeon_inverter_battery_consumed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery autonomy', + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Battery consumed', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + 'entity_id': 'sensor.imeon_inverter_battery_consumed', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4.5', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_battery_charge_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery charge time', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_charge_time', - 'unique_id': '111111111111111_battery_charge_time', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery charge time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_charge_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '120', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_power-entry] @@ -220,7 +164,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2500.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-entry] @@ -273,7 +217,67 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '78.0', + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'charged', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery status', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_status', + 'unique_id': '111111111111111_battery_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Imeon inverter Battery status', + 'options': list([ + 'charging', + 'discharging', + 'charged', + ]), + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_battery_stored-entry] @@ -304,7 +308,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Battery stored', 'platform': 'imeon_inverter', @@ -313,23 +317,79 @@ 'supported_features': 0, 'translation_key': 'battery_stored', 'unique_id': '111111111111111_battery_stored', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.imeon_inverter_battery_stored-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy_storage', + 'device_class': 'power', 'friendly_name': 'Imeon inverter Battery stored', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.imeon_inverter_battery_stored', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10.2', + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_building_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_building_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Building consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_building_consumption', + 'unique_id': '111111111111111_monitoring_minute_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_building_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Building consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_building_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_charging_current_limit-entry] @@ -385,7 +445,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_component_temperature-entry] @@ -441,7 +501,63 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '45.5', + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_consumption', + 'unique_id': '111111111111111_monitoring_minute_grid_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Grid consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l1-entry] @@ -497,7 +613,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '12.5', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l2-entry] @@ -553,7 +669,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10.8', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_current_l3-entry] @@ -609,7 +725,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '11.2', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_frequency-entry] @@ -665,7 +781,119 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50.0', + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_injection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_injection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid injection', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_injection', + 'unique_id': '111111111111111_monitoring_minute_grid_injection', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_injection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Grid injection', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_injection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_power_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_power_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power flow', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_power_flow', + 'unique_id': '111111111111111_monitoring_minute_grid_power_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_power_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Grid power flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_power_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-entry] @@ -721,7 +949,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '230.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-entry] @@ -777,7 +1005,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '229.5', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-entry] @@ -833,7 +1061,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '230.1', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_injection_power_limit-entry] @@ -889,7 +1117,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5000.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l1-entry] @@ -945,7 +1173,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1000.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l2-entry] @@ -1001,7 +1229,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '950.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_l3-entry] @@ -1057,7 +1285,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '980.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_input_power_total-entry] @@ -1113,7 +1341,69 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2930.0', + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_inverter_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unsynchronized', + 'grid_consumption', + 'grid_injection', + 'grid_synchronised_but_not_used', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_inverter_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter state', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manager_inverter_state', + 'unique_id': '111111111111111_manager_inverter_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.imeon_inverter_inverter_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Imeon inverter Inverter state', + 'options': list([ + 'unsynchronized', + 'grid_consumption', + 'grid_injection', + 'grid_synchronised_but_not_used', + ]), + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_inverter_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_meter_power-entry] @@ -1169,788 +1459,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2000.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Meter power protocol', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'meter_power_protocol', - 'unique_id': '111111111111111_meter_power_protocol', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Meter power protocol', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2018.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring building consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_building_consumption', - 'unique_id': '111111111111111_monitoring_building_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring building consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3000.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring building consumption (minute)', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_minute_building_consumption', - 'unique_id': '111111111111111_monitoring_minute_building_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Monitoring building consumption (minute)', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '50.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Monitoring economy factor', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_economy_factor', - 'unique_id': '111111111111111_monitoring_economy_factor', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Imeon inverter Monitoring economy factor', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.8', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_consumption', - 'unique_id': '111111111111111_monitoring_grid_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '500.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid consumption (minute)', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_minute_grid_consumption', - 'unique_id': '111111111111111_monitoring_minute_grid_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Monitoring grid consumption (minute)', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8.3', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid injection', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_injection', - 'unique_id': '111111111111111_monitoring_grid_injection', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid injection', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '700.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid injection (minute)', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_minute_grid_injection', - 'unique_id': '111111111111111_monitoring_minute_grid_injection', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Monitoring grid injection (minute)', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '11.7', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid power flow', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_power_flow', - 'unique_id': '111111111111111_monitoring_grid_power_flow', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid power flow', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-200.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid power flow (minute)', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_minute_grid_power_flow', - 'unique_id': '111111111111111_monitoring_minute_grid_power_flow', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Monitoring grid power flow (minute)', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-3.4', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Monitoring self-consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_self_consumption', - 'unique_id': '111111111111111_monitoring_self_consumption', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Imeon inverter Monitoring self-consumption', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '85.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Monitoring self-sufficiency', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_self_sufficiency', - 'unique_id': '111111111111111_monitoring_self_sufficiency', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Imeon inverter Monitoring self-sufficiency', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '90.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring solar production', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_solar_production', - 'unique_id': '111111111111111_monitoring_solar_production', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring solar production', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2600.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring solar production (minute)', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_minute_solar_production', - 'unique_id': '111111111111111_monitoring_minute_solar_production', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Monitoring solar production (minute)', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '43.3', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l1-entry] @@ -2006,7 +1515,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l2-entry] @@ -2062,7 +1571,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '14.5', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_current_l3-entry] @@ -2118,7 +1627,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15.2', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_frequency-entry] @@ -2174,7 +1683,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '49.9', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l1-entry] @@ -2230,7 +1739,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1100.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l2-entry] @@ -2286,7 +1795,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1080.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_l3-entry] @@ -2342,7 +1851,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1120.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_power_total-entry] @@ -2398,7 +1907,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3300.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l1-entry] @@ -2454,7 +1963,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '231.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l2-entry] @@ -2510,7 +2019,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '229.8', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_output_voltage_l3-entry] @@ -2566,7 +2075,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '230.2', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_consumed-entry] @@ -2575,7 +2084,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2597,7 +2106,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'PV consumed', 'platform': 'imeon_inverter', @@ -2606,23 +2115,23 @@ 'supported_features': 0, 'translation_key': 'pv_consumed', 'unique_id': '111111111111111_pv_consumed', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.imeon_inverter_pv_consumed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'power', 'friendly_name': 'Imeon inverter PV consumed', - 'state_class': , - 'unit_of_measurement': , + 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.imeon_inverter_pv_consumed', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1500.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_injected-entry] @@ -2631,7 +2140,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2653,7 +2162,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'PV injected', 'platform': 'imeon_inverter', @@ -2662,23 +2171,23 @@ 'supported_features': 0, 'translation_key': 'pv_injected', 'unique_id': '111111111111111_pv_injected', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.imeon_inverter_pv_injected-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', + 'device_class': 'power', 'friendly_name': 'Imeon inverter PV injected', - 'state_class': , - 'unit_of_measurement': , + 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.imeon_inverter_pv_injected', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '800.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_1-entry] @@ -2734,7 +2243,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1200.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_2-entry] @@ -2790,7 +2299,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1300.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.imeon_inverter_pv_power_total-entry] @@ -2846,6 +2355,590 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2500.0', + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_consumption', + 'unique_id': '111111111111111_monitoring_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Self-consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_self_sufficiency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_self_sufficiency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-sufficiency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_sufficiency', + 'unique_id': '111111111111111_monitoring_self_sufficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_self_sufficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Self-sufficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_self_sufficiency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_solar_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_solar_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar production', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_solar_production', + 'unique_id': '111111111111111_monitoring_minute_solar_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_solar_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Solar production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_solar_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_timeline_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'com_lost', + 'warning_grid', + 'warning_pv', + 'warning_bat', + 'error_ond', + 'error_soft', + 'error_pv', + 'error_grid', + 'error_bat', + 'good_1', + 'info_soft', + 'info_ond', + 'info_bat', + 'info_smartlo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_timeline_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timeline status', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timeline_type_msg', + 'unique_id': '111111111111111_timeline_type_msg', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.imeon_inverter_timeline_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Imeon inverter Timeline status', + 'options': list([ + 'com_lost', + 'warning_grid', + 'warning_pv', + 'warning_bat', + 'error_ond', + 'error_soft', + 'error_pv', + 'error_grid', + 'error_bat', + 'good_1', + 'info_soft', + 'info_ond', + 'info_bat', + 'info_smartlo', + ]), + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_timeline_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_battery_consumed_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_today_battery_consumed_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today battery-consumed energy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_battery_consumed', + 'unique_id': '111111111111111_energy_battery_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_battery_consumed_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Today battery-consumed energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_today_battery_consumed_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_battery_stored_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_today_battery_stored_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today battery-stored energy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_battery_stored', + 'unique_id': '111111111111111_energy_battery_stored', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_battery_stored_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Today battery-stored energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_today_battery_stored_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_building_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_today_building_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today building consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_building_consumption', + 'unique_id': '111111111111111_energy_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_building_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Today building consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_today_building_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_grid_consumed_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_today_grid_consumed_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today grid-consumed energy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_grid_consumed', + 'unique_id': '111111111111111_energy_grid_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_grid_consumed_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Today grid-consumed energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_today_grid_consumed_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_grid_injected_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_today_grid_injected_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today grid-injected energy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_grid_injected', + 'unique_id': '111111111111111_energy_grid_injected', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_grid_injected_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Today grid-injected energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_today_grid_injected_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_pv_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_today_pv_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today PV energy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_pv', + 'unique_id': '111111111111111_energy_pv', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_today_pv_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Today PV energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_today_pv_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- diff --git a/tests/components/imeon_inverter/test_sensor.py b/tests/components/imeon_inverter/test_sensor.py index 194864a67a2..ec50594f6ba 100644 --- a/tests/components/imeon_inverter/test_sensor.py +++ b/tests/components/imeon_inverter/test_sensor.py @@ -1,16 +1,20 @@ """Test the Imeon Inverter sensors.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.imeon_inverter.coordinator import INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_sensors( @@ -24,3 +28,51 @@ async def test_sensors( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "exception", + [ + TimeoutError, + ClientError, + ValueError, + ], +) +@pytest.mark.asyncio +async def test_sensor_unavailable_on_update_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_imeon_inverter: MagicMock, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test that sensor becomes unavailable when update raises an error.""" + entity_id = "sensor.imeon_inverter_battery_power" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_imeon_inverter.update.side_effect = exception + + freezer.tick(INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_imeon_inverter.update.side_effect = None + + freezer.tick(INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index a10b9b54532..0ba09c27e0e 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from imgw_pib import HydrologicalData, SensorData +from imgw_pib import Alert, HydrologicalData, SensorData import pytest from homeassistant.components.imgw_pib.const import DOMAIN @@ -23,6 +23,15 @@ HYDROLOGICAL_DATA = HydrologicalData( flood_warning=None, water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), + water_flow=SensorData(name="Water Flow", value=123.45), + water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), + hydrological_alert=Alert( + value="rapid_water_level_rise", + valid_from=datetime(2024, 4, 27, 7, 0, tzinfo=UTC), + valid_to=datetime(2024, 4, 28, 11, 0, tzinfo=UTC), + level="yellow", + probability=80, + ), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 97453930c1e..420a9300d3d 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -34,9 +34,24 @@ 'unit': None, 'value': None, }), + 'hydrological_alert': dict({ + 'level': 'yellow', + 'probability': 80, + 'valid_from': '2024-04-27T07:00:00+00:00', + 'valid_to': '2024-04-28T11:00:00+00:00', + 'value': 'rapid_water_level_rise', + }), + 'latitude': None, + 'longitude': None, 'river': 'River Name', 'station': 'Station Name', 'station_id': '123', + 'water_flow': dict({ + 'name': 'Water Flow', + 'unit': None, + 'value': 123.45, + }), + 'water_flow_measurement_date': '2024-04-27T10:05:00+00:00', 'water_level': dict({ 'name': 'Water Level', 'unit': None, diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 5b588af4518..cdefd949560 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,4 +1,128 @@ # serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + 'exceeding_the_warning_level', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrological alert', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hydrological_alert', + 'unique_id': '123_hydrological_alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'enum', + 'friendly_name': 'River Name (Station Name) Hydrological alert', + 'level': 'yellow', + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + 'exceeding_the_warning_level', + ]), + 'probability': 80, + 'valid_from': datetime.datetime(2024, 4, 27, 7, 0, tzinfo=datetime.timezone.utc), + 'valid_to': datetime.datetime(2024, 4, 28, 11, 0, tzinfo=datetime.timezone.utc), + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rapid_water_level_rise', + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_water_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water flow', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_flow', + 'unique_id': '123_water_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'River Name (Station Name) Water flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_water_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.45', + }) +# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 6c7813cbd85..adcbf14d97b 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -1,19 +1,33 @@ """Common fixtures for the Immich tests.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import AsyncMock, patch +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch -from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers +from aioimmich import ( + ImmichAlbums, + ImmichAssests, + ImmichPeople, + ImmichSearch, + ImmichServer, + ImmichTags, + ImmichUsers, +) +from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse +from aioimmich.assets.models import ImmichAssetUploadResponse +from aioimmich.people.models import ImmichPerson from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, ImmichServerStorage, ImmichServerVersionCheck, ) +from aioimmich.tags.models import ImmichTag from aioimmich.users.models import ImmichUserObject import pytest from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.media_source import PlayMedia from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -25,7 +39,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReaderChunked -from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS +from .const import ( + MOCK_ALBUM_WITH_ASSETS, + MOCK_ALBUM_WITHOUT_ASSETS, + MOCK_PEOPLE_ASSETS, + MOCK_TAGS_ASSETS, +) from tests.common import MockConfigEntry @@ -62,6 +81,12 @@ def mock_immich_albums() -> AsyncMock: mock = AsyncMock(spec=ImmichAlbums) mock.async_get_all_albums.return_value = [MOCK_ALBUM_WITHOUT_ASSETS] mock.async_get_album_info.return_value = MOCK_ALBUM_WITH_ASSETS + mock.async_add_assets_to_album.return_value = [ + ImmichAddAssetsToAlbumResponse.from_dict( + {"id": "abcdef-0123456789", "success": True} + ) + ] + return mock @@ -71,6 +96,61 @@ def mock_immich_assets() -> AsyncMock: mock = AsyncMock(spec=ImmichAssests) mock.async_view_asset.return_value = b"xxxx" mock.async_play_video_stream.return_value = MockStreamReaderChunked(b"xxxx") + mock.async_upload_asset.return_value = ImmichAssetUploadResponse.from_dict( + {"id": "abcdef-0123456789", "status": "created"} + ) + return mock + + +@pytest.fixture +def mock_immich_people() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichPeople) + mock.async_get_all_people.return_value = [ + ImmichPerson.from_dict( + { + "id": "6176838a-ac5a-4d1f-9a35-91c591d962d8", + "name": "Me", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/61/76/6176838a-ac5a-4d1f-9a35-91c591d962d8.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-11T11:07:41.651Z", + } + ), + ImmichPerson.from_dict( + { + "id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f", + "name": "I", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/3e/66/3e66aa4a-a4a8-41a4-86fe-2ae5e490078f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-19T22:10:21.953Z", + } + ), + ImmichPerson.from_dict( + { + "id": "a3c83297-684a-4576-82dc-b07432e8a18f", + "name": "Myself", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/a3/c8/a3c83297-684a-4576-82dc-b07432e8a18f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-12T21:07:04.044Z", + } + ), + ] + mock.async_get_person_thumbnail.return_value = b"yyyy" + return mock + + +@pytest.fixture +def mock_immich_search() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichSearch) + mock.async_get_all_by_person_ids.return_value = MOCK_PEOPLE_ASSETS + mock.async_get_all_by_tag_ids.return_value = MOCK_TAGS_ASSETS return mock @@ -140,6 +220,33 @@ def mock_immich_server() -> AsyncMock: return mock +@pytest.fixture +def mock_immich_tags() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichTags) + mock.async_get_all_tags.return_value = [ + ImmichTag.from_dict( + { + "id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + "name": "Halloween", + "value": "Halloween", + "createdAt": "2025-05-12T20:00:45.220Z", + "updatedAt": "2025-05-12T20:00:47.224Z", + }, + ), + ImmichTag.from_dict( + { + "id": "69bd487f-dc1e-4420-94c6-656f0515773d", + "name": "Holidays", + "value": "Holidays", + "createdAt": "2025-05-12T20:00:49.967Z", + "updatedAt": "2025-05-12T20:00:55.575Z", + }, + ), + ] + return mock + + @pytest.fixture def mock_immich_user() -> AsyncMock: """Mock the Immich server.""" @@ -172,7 +279,10 @@ def mock_immich_user() -> AsyncMock: async def mock_immich( mock_immich_albums: AsyncMock, mock_immich_assets: AsyncMock, + mock_immich_people: AsyncMock, + mock_immich_search: AsyncMock, mock_immich_server: AsyncMock, + mock_immich_tags: AsyncMock, mock_immich_user: AsyncMock, ) -> AsyncGenerator[AsyncMock]: """Mock the Immich API.""" @@ -183,7 +293,10 @@ async def mock_immich( client = mock_immich.return_value client.albums = mock_immich_albums client.assets = mock_immich_assets + client.people = mock_immich_people + client.search = mock_immich_search client.server = mock_immich_server + client.tags = mock_immich_tags client.users = mock_immich_user yield client @@ -195,6 +308,20 @@ async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock: return mock_immich +@pytest.fixture +def mock_media_source() -> Generator[MagicMock]: + """Mock the media source.""" + with patch( + "homeassistant.components.immich.services.async_resolve_media", + return_value=PlayMedia( + url="media-source://media_source/local/screenshot.jpg", + mime_type="image/jpeg", + path=Path("/media/screenshot.jpg"), + ), + ) as mock_media: + yield mock_media + + @pytest.fixture async def setup_media_source(hass: HomeAssistant) -> None: """Set up media source.""" diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index 97721bc7dbc..af718c4b754 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -1,6 +1,7 @@ """Constants for the Immich integration tests.""" from aioimmich.albums.models import ImmichAlbum +from aioimmich.assets.models import ImmichAsset from homeassistant.const import ( CONF_API_KEY, @@ -113,3 +114,131 @@ MOCK_ALBUM_WITH_ASSETS = ImmichAlbum.from_dict( ], } ) + +MOCK_PEOPLE_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "deviceAssetId": "1000092019", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/8e/a3/8ea31ee8-49c3-4be9-aa9d-b8ef26ba0abe.jpg", + "originalFileName": "20250714_201122.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILGeMlPaJaMWIeagJcJSA==", + "fileCreatedAt": "2025-07-14T18:11:22.648Z", + "fileModifiedAt": "2025-07-14T18:11:25.000Z", + "localDateTime": "2025-07-14T20:11:22.648Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "GcBJkDFoXx9d/wyl1xH89R4/NBQ=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + } + ), + ImmichAsset.from_dict( + { + "id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "deviceAssetId": "1000092018", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/f5/b4/f5b4b200-47dd-45e8-98a4-4128df3f9189.jpg", + "originalFileName": "20250714_201121.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILHeMlPeJaMSJmKgJcIWQ==", + "fileCreatedAt": "2025-07-14T18:11:21.582Z", + "fileModifiedAt": "2025-07-14T18:11:24.000Z", + "localDateTime": "2025-07-14T20:11:21.582Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "X6kMpPulu/HJQnKmTqCoQYl3Sjc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] + +MOCK_TAGS_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "deviceAssetId": "2132393", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/07/d0/07d04d86-7188-4335-95ca-9bd9fd2b399d.JPG", + "originalFileName": "20110306_025024.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "WCgSFYRXaYdQiYineIiHd4SghQUY", + "fileCreatedAt": "2011-03-06T01:50:24.000Z", + "fileModifiedAt": "2011-03-06T01:50:24.000Z", + "localDateTime": "2011-03-06T02:50:24.000Z", + "updatedAt": "2025-07-26T10:16:39.477Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "eNwN0AN2hEYZJJkonl7ylGzJzko=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), + ImmichAsset.from_dict( + { + "id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "deviceAssetId": "2142137", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/4a/f4/4af42484-86f8-47a0-958a-f32da89ee03a.JPG", + "originalFileName": "20110306_024053.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "4AcKFYZPZnhSmGl5daaYeG859ytT", + "fileCreatedAt": "2011-03-06T01:40:53.000Z", + "fileModifiedAt": "2011-03-06T01:40:52.000Z", + "localDateTime": "2011-03-06T02:40:53.000Z", + "updatedAt": "2025-07-26T10:16:39.474Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "VtokCjIwKqnHBFzH3kHakIJiq5I=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 5b396a780cc..6bd23b272ed 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -26,7 +26,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked from . import setup_integration -from .const import MOCK_ALBUM_WITHOUT_ASSETS from tests.common import MockConfigEntry @@ -143,7 +142,8 @@ async def test_browse_media_get_root( result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 + assert len(result.children) == 3 + media_file = result.children[0] assert isinstance(media_file, BrowseMedia) assert media_file.title == "albums" @@ -151,174 +151,289 @@ async def test_browse_media_get_root( "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums" ) - -async def test_browse_media_get_albums( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - item = MediaSourceItem( - hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums", None - ) - result = await source.async_browse_media(item) - - assert result - assert len(result.children) == 1 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.title == "My Album" - assert media_file.media_content_id == ( - "media-source://immich/" - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" - ) - - -async def test_browse_media_get_albums_error( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media with unknown album.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - # exception in get_albums() - mock_immich.albums.async_get_all_albums.side_effect = ImmichError( - { - "message": "Not found or no album.read access", - "error": "Bad Request", - "statusCode": 400, - "correlationId": "e0hlizyl", - } - ) - - source = await async_get_media_source(hass) - - item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}|albums", None) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - -async def test_browse_media_get_album_items_error( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - - # unknown album - mock_immich.albums.async_get_album_info.return_value = MOCK_ALBUM_WITHOUT_ASSETS - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - # exception in async_get_album_info() - mock_immich.albums.async_get_album_info.side_effect = ImmichError( - { - "message": "Not found or no album.read access", - "error": "Bad Request", - "statusCode": 400, - "correlationId": "e0hlizyl", - } - ) - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - -async def test_browse_media_get_album_items( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert len(result.children) == 2 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4|filename.jpg|image/jpeg" - ) - assert media_file.title == "filename.jpg" - assert media_file.media_class == MediaClass.IMAGE - assert media_file.media_content_type == "image/jpeg" - assert media_file.can_play is False - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg" - ) - media_file = result.children[1] assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4" + assert media_file.title == "people" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|people" ) - assert media_file.title == "filename.mp4" - assert media_file.media_class == MediaClass.VIDEO - assert media_file.media_content_type == "video/mp4" - assert media_file.can_play is True - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg" + + media_file = result.children[2] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "tags" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|tags" ) +@pytest.mark.parametrize( + ("collection", "children"), + [ + ( + "albums", + [{"title": "My Album", "asset_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6"}], + ), + ( + "people", + [ + {"title": "Me", "asset_id": "6176838a-ac5a-4d1f-9a35-91c591d962d8"}, + {"title": "I", "asset_id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f"}, + {"title": "Myself", "asset_id": "a3c83297-684a-4576-82dc-b07432e8a18f"}, + ], + ), + ( + "tags", + [ + { + "title": "Halloween", + "asset_id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + }, + { + "title": "Holidays", + "asset_id": "69bd487f-dc1e-4420-94c6-656f0515773d", + }, + ], + ), + ], +) +async def test_browse_media_collections( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + children: list[dict], +) -> None: + """Test browse through collections.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == len(children) + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == child["title"] + assert media_file.media_content_id == ( + "media-source://immich/" + f"{mock_config_entry.unique_id}|{collection}|" + f"{child['asset_id']}" + ) + + +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_all_albums")), + ("people", ("people", "async_get_all_people")), + ("tags", ("tags", "async_get_all_tags")), + ], +) +async def test_browse_media_collections_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], +) -> None: + """Test browse_media with unknown collection.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_album_info")), + ("people", ("search", "async_get_all_by_person_ids")), + ("tags", ("search", "async_get_all_by_tag_ids")), + ], +) +async def test_browse_media_collection_items_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.parametrize( + ("collection", "collection_id", "children"), + [ + ( + "albums", + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + [ + { + "original_file_name": "filename.jpg", + "asset_id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "filename.mp4", + "asset_id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", + "media_class": MediaClass.VIDEO, + "media_content_type": "video/mp4", + "thumb_mime_type": "image/jpeg", + "can_play": True, + }, + ], + ), + ( + "people", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20250714_201122.jpg", + "asset_id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20250714_201121.jpg", + "asset_id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ( + "tags", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20110306_025024.jpg", + "asset_id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20110306_024053.jpg", + "asset_id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ], +) +async def test_browse_media_collection_get_items( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + collection_id: str, + children: list[dict], +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|{collection_id}", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == len(children) + + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + f"{mock_config_entry.unique_id}|{collection}|{collection_id}|" + f"{child['asset_id']}|{child['original_file_name']}|{child['media_content_type']}" + ) + assert media_file.title == child["original_file_name"] + assert media_file.media_class == child["media_class"] + assert media_file.media_content_type == child["media_content_type"] + assert media_file.can_play is child["can_play"] + assert not media_file.can_expand + assert media_file.thumbnail == ( + f"/immich/{mock_config_entry.unique_id}/" + f"{child['asset_id']}/thumbnail/{child['thumb_mime_type']}" + ) + + async def test_media_view( hass: HomeAssistant, tmp_path: Path, @@ -362,6 +477,22 @@ async def test_media_view( "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", ) + # exception in async_get_person_thumbnail() + mock_immich.people.async_get_person_thumbnail.side_effect = ImmichError( + { + "message": "Not found or no asset.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + # exception in async_play_video_stream() mock_immich.assets.async_play_video_stream.side_effect = ImmichError( { @@ -396,6 +527,24 @@ async def test_media_view( ) assert isinstance(result, web.Response) + mock_immich.people.async_get_person_thumbnail.side_effect = None + mock_immich.people.async_get_person_thumbnail.return_value = b"xxxx" + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + assert isinstance(result, web.Response) + + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/fullsize/image/jpeg", + ) + assert isinstance(result, web.Response) + mock_immich.assets.async_play_video_stream.side_effect = None mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked( b"xxxx" diff --git a/tests/components/immich/test_services.py b/tests/components/immich/test_services.py new file mode 100644 index 00000000000..5ba7cf96408 --- /dev/null +++ b/tests/components/immich/test_services.py @@ -0,0 +1,277 @@ +"""Test the Immich services.""" + +from unittest.mock import Mock, patch + +from aioimmich.exceptions import ImmichError, ImmichNotFoundError +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.immich.services import SERVICE_UPLOAD_FILE +from homeassistant.components.media_source import PlayMedia +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_services( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup of immich services.""" + await setup_integration(hass, mock_config_entry) + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_UPLOAD_FILE in services + + +async def test_upload_file( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg") + mock_immich.albums.async_get_album_info.assert_not_called() + mock_immich.albums.async_add_assets_to_album.assert_not_called() + + +async def test_upload_file_to_album( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service with target album_id.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) + + mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg") + mock_immich.albums.async_get_album_info.assert_called_with( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", True + ) + mock_immich.albums.async_add_assets_to_album.assert_called_with( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", ["abcdef-0123456789"] + ) + + +async def test_upload_file_config_entry_not_found( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload_file service raising config_entry_not_found.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError, match="Config entry not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": "unknown_entry", + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_config_entry_not_loaded( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload_file service raising config_entry_not_loaded.""" + mock_config_entry.disabled_by = er.RegistryEntryDisabler.USER + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError, match="Config entry not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_only_local_media_supported( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising only_local_media_supported.""" + await setup_integration(hass, mock_config_entry) + with ( + patch( + "homeassistant.components.immich.services.async_resolve_media", + return_value=PlayMedia( + url="media-source://media_source/camera/some_entity_id", + mime_type="image/jpeg", + path=None, # Simulate non-local media + ), + ), + pytest.raises( + ServiceValidationError, + match="Only local media files are currently supported", + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_album_not_found( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising album_not_found.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.albums.async_get_album_info.side_effect = ImmichNotFoundError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + + with pytest.raises( + ServiceValidationError, + match="Album with ID `721e1a4b-aa12-441e-8d3b-5ac7ab283bb6` not found", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) + + +async def test_upload_file_upload_failed( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising upload_failed.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.assets.async_upload_asset.side_effect = ImmichError( + { + "message": "Boom! Upload failed", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + with pytest.raises( + ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_to_album_upload_failed( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service with target album_id raising upload_failed.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.albums.async_add_assets_to_album.side_effect = ImmichError( + { + "message": "Boom! Add to album failed.", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + with pytest.raises( + ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index e3579182b3d..2d9a8273ab6 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -22,13 +22,13 @@ from tests.common import MockConfigEntry DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="rfgateway", ip="192.168.1.12", - macaddress="0004A3DEADFF", + macaddress=dr.format_mac("00:04:A3:DE:AD:FF").replace(":", ""), ) DHCP_SERVICE_INFO_ALT = DhcpServiceInfo( hostname="rfgateway", ip="192.168.1.99", - macaddress="0004A3DEADFF", + macaddress=dr.format_mac("00:04:A3:DE:AD:FF").replace(":", ""), ) diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 7228f64448b..1daadc9ffe8 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -29,7 +29,6 @@ def _make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=MONOTONIC_TIME(), advertisement=None, diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index b2e99836477..b82bbe59203 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -54,12 +54,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_methods(hass: HomeAssistant) -> None: diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index e59d0543751..78cfd0a3d8b 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -47,12 +47,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 8ea1c2e25b6..94166a8ab7e 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -98,16 +98,19 @@ async def decrement(hass: HomeAssistant, entity_id: str) -> None: ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 0, "max": 10, "initial": 11}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 153d8ed848d..c53e105bd09 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -70,17 +70,18 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"bad_initial": {"options": [1, 2], "initial": 3}}, - ] + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_select_option(hass: HomeAssistant) -> None: diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 2ca1d39a983..c0c18a5153c 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -81,16 +81,21 @@ async def async_set_value(hass: HomeAssistant, entity_id: str, value: str) -> No ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, - {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 51, "max": 50}}, + {"test_1": {"min": -1, "max": 100}}, + {"test_1": {"min": 0, "max": 256}}, + {"test_1": {"min": 0, "max": 3, "initial": "aaaaa"}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant) -> None: diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 0ce3297a2ff..50243551d37 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components import integration from homeassistant.components.integration.config_flow import ConfigFlowHandler from homeassistant.components.integration.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -83,6 +83,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -176,6 +177,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Set up entities, with backing devices and config entries input_entry = _create_mock_entity("sensor", "input") valid_entry = _create_mock_entity("sensor", "valid") + assert input_entry.device_id != valid_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -193,17 +195,21 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(input_entry) + assert config_entry.entry_id not in _get_device_config_entries(input_entry) assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == input_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "source": "sensor.valid"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(input_entry) - assert config_entry.entry_id in _get_device_config_entries(valid_entry) + assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == valid_entry.device_id async def test_device_cleaning( @@ -276,7 +282,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(integration_config_entry.entry_id) @@ -291,7 +297,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -302,6 +308,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the integration config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the integration config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -318,7 +372,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -335,7 +389,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +420,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -377,7 +435,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -410,7 +472,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert integration_config_entry.entry_id not in sensor_device_2.config_entries @@ -427,11 +489,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is moved to the other device + # Check that the entity is linked to the other device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert integration_config_entry.entry_id in sensor_device_2.config_entries + assert integration_config_entry.entry_id not in sensor_device_2.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -456,7 +522,7 @@ async def test_async_handle_source_entity_new_entity_id( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -474,12 +540,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the integration config entry is updated with the new entity ID assert integration_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes integration config entry from device.""" + + integration_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=1, + minor_version=1, + ) + integration_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=integration_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + assert integration_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + assert integration_config_entry.version == 1 + assert integration_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": "sensor.test", + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index ba4a6bdf198..bda0cefb572 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -21,12 +21,19 @@ from homeassistant.const import ( UnitOfTime, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import ( condition, device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -294,25 +301,54 @@ async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> No assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ + # time, value, attributes ( - (20, 10, 1.67), - (30, 30, 5.0), - (40, 5, 7.92), - (50, 5, 8.75), - (60, 0, 9.17), + ( + (0, 0, {}), + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (0, 1.67, 5.0, 7.92, 8.75, 9.58, 10.0), + ), + ( + ( + (0, 0, {}), + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), # This fires a state change + (60, 5, {"foo": "baz"}), # This fires a state change + (70, 0, {}), + ), + (0, 1.67, 5.0, 7.92, 8.75, 9.58, 10.0), ), ], ) async def test_trapezoidal( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: """Test integration sensor state.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -320,51 +356,89 @@ async def test_trapezoidal( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set(entity_id, 0, {}) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: # Testing a power sensor with non-monotonic intervals and values - for time, value, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = [events[0].data["new_state"].state] + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[1:] + ] + assert states == ["unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ + ( # time, value, attributes, expected + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (0, 1.67, 6.67, 7.5, 8.33, 9.17), + ), ( - (20, 10, 0.0), - (30, 30, 1.67), - (40, 5, 6.67), - (50, 5, 7.5), - (60, 0, 8.33), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), + (60, 5, {"foo": "baz"}), + (70, 0, {}), + ), + (0, 1.67, 6.67, 7.5, 8.33, 9.17), ), ], ) async def test_left( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with left Riemann method.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -373,53 +447,96 @@ async def test_left( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = ( + [events[0].data["new_state"].state] + + [events[1].data["new_state"].state] + + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[2:] + ] + ) + assert states == ["unknown", "unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ + # time, value, attributes, expected ( - (20, 10, 3.33), - (30, 30, 8.33), - (40, 5, 9.17), - (50, 5, 10.0), - (60, 0, 10.0), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (3.33, 8.33, 9.17, 10.0, 10.83), + ), + ( + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), # This fires a state change + (60, 5, {"foo": "baz"}), # This fires a state change + (70, 0, {}), + ), + (3.33, 8.33, 9.17, 10.0, 10.83), ), ], ) async def test_right( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with right Riemann method.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -428,31 +545,47 @@ async def test_right( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = ( + [events[0].data["new_state"].state] + + [events[1].data["new_state"].state] + + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[2:] + ] + ) + assert states == ["unknown", "unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 058a5d35cd0..41e79911154 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -29,7 +29,6 @@ 'TestLS', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'iotty', @@ -39,7 +38,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index 479ee2fde7b..60abf8a8008 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -131,7 +131,7 @@ def mock_ble_device() -> Generator[MagicMock]: with patch( "homeassistant.components.bluetooth.async_ble_device_from_address", return_value=BLEDevice( - address="c0:ff:ee:c0:ff:ee", name=DEFAULT_NAME, rssi=-50, details={} + address="c0:ff:ee:c0:ff:ee", name=DEFAULT_NAME, details={} ), ) as ble_device: yield ble_device diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 37d8b1f4819..377d29f4a71 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -6,7 +6,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 450, - 'min': 0, + 'min': 250, 'mode': , 'step': 10, }), @@ -27,7 +27,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Boost temperature', 'platform': 'iron_os', @@ -42,10 +42,9 @@ # name: test_state[number.pinecil_boost_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Boost temperature', 'max': 450, - 'min': 0, + 'min': 250, 'mode': , 'step': 10, 'unit_of_measurement': , @@ -95,7 +94,7 @@ 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_calibration_offset', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_state[number.pinecil_calibration_offset-state] @@ -106,7 +105,7 @@ 'min': 100, 'mode': , 'step': 1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.pinecil_calibration_offset', @@ -839,7 +838,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Setpoint temperature', 'platform': 'iron_os', @@ -854,7 +853,6 @@ # name: test_state[number.pinecil_setpoint_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Setpoint temperature', 'max': 450, 'min': 10, @@ -1015,7 +1013,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Sleep temperature', 'platform': 'iron_os', @@ -1030,7 +1028,6 @@ # name: test_state[number.pinecil_sleep_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Sleep temperature', 'max': 450, 'min': 10, diff --git a/tests/components/iron_os/snapshots/test_sensor.ambr b/tests/components/iron_os/snapshots/test_sensor.ambr index 39dda49d313..caab12d4120 100644 --- a/tests/components/iron_os/snapshots/test_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_sensor.ambr @@ -566,7 +566,7 @@ 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_voltage', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.pinecil_raw_tip_voltage-state] @@ -575,7 +575,7 @@ 'device_class': 'voltage', 'friendly_name': 'Pinecil Raw tip voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.pinecil_raw_tip_voltage', diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr index ff231c4050f..a0591c88fdf 100644 --- a/tests/components/iron_os/snapshots/test_switch.ambr +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -47,6 +47,54 @@ 'state': 'on', }) # --- +# name: test_switch_platform[switch.pinecil_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boost', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Boost', + }), + 'context': , + 'entity_id': 'switch.pinecil_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_platform[switch.pinecil_calibrate_cjc-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index 9a4ba53f338..b9c11bf52ef 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -5,17 +5,22 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CharSetting, CommunicationError +from pynecil import CharSetting, CommunicationError, TempUnit import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.iron_os.const import ( + MAX_TEMP_F, + MIN_BOOST_TEMP_F, + MIN_TEMP_F, +) from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -56,6 +61,47 @@ async def test_state( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize( + ("entity_id", "min_value", "max_value"), + [ + ("number.pinecil_setpoint_temperature", MIN_TEMP_F, MAX_TEMP_F), + ("number.pinecil_boost_temperature", MIN_BOOST_TEMP_F, MAX_TEMP_F), + ("number.pinecil_long_press_temperature_step", 5, 90), + ("number.pinecil_short_press_temperature_step", 1, 50), + ("number.pinecil_sleep_temperature", MIN_TEMP_F, MAX_TEMP_F), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_state_fahrenheit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_pynecil: AsyncMock, + entity_id: str, + min_value: int, + max_value: int, +) -> None: + """Test with temp unit set to fahrenheit.""" + + mock_pynecil.get_settings.return_value["temp_unit"] = TempUnit.FAHRENHEIT + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + + assert state.attributes["min"] == min_value + assert state.attributes["max"] == max_value + + @pytest.mark.parametrize( ("entity_id", "characteristic", "value", "expected_value"), [ @@ -202,3 +248,26 @@ async def test_set_value_exception( target={ATTR_ENTITY_ID: "number.pinecil_setpoint_temperature"}, blocking=True, ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_boost_temp_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test boost temp input is unavailable when off.""" + mock_pynecil.get_settings.return_value["boost_temp"] = 0 + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("number.pinecil_boost_temperature")) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/iron_os/test_switch.py b/tests/components/iron_os/test_switch.py index d52c3fd333b..0cc60a7dde7 100644 --- a/tests/components/iron_os/test_switch.py +++ b/tests/components/iron_os/test_switch.py @@ -5,7 +5,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CharSetting, CommunicationError +from pynecil import CharSetting, CommunicationError, TempUnit import pytest from syrupy.assertion import SnapshotAssertion @@ -110,6 +110,47 @@ async def test_turn_on_off_toggle( mock_pynecil.write.assert_called_once_with(target, value) +@pytest.mark.parametrize( + ("service", "value", "temp_unit"), + [ + (SERVICE_TOGGLE, False, TempUnit.CELSIUS), + (SERVICE_TURN_OFF, False, TempUnit.CELSIUS), + (SERVICE_TURN_ON, 250, TempUnit.CELSIUS), + (SERVICE_TURN_ON, 480, TempUnit.FAHRENHEIT), + ], +) +@pytest.mark.usefixtures("ble_device") +async def test_turn_on_off_toggle_boost( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, + service: str, + value: bool, + temp_unit: TempUnit, +) -> None: + """Test the IronOS switch turn on/off, toggle services.""" + mock_pynecil.get_settings.return_value["temp_unit"] = temp_unit + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + service_data={ATTR_ENTITY_ID: "switch.pinecil_boost"}, + blocking=True, + ) + assert len(mock_pynecil.write.mock_calls) == 1 + mock_pynecil.write.assert_called_once_with(CharSetting.BOOST_TEMP, value) + + @pytest.mark.parametrize( "service", [SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON], diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 58977c99b59..7be1302aa4f 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -96,12 +96,16 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", @@ -115,16 +119,21 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "104", + "unit": "Einheiten", "additionalValue": "113,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,1", + "unit": "m³", "additionalValue": "61,1", + "additionalUnit": "kWh", }, { "type": "water", "value": "6,8", + "unit": "m³", }, ], }, @@ -200,16 +209,21 @@ def extend_statistics(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "9000", + "unit": "Einheiten", "additionalValue": "9000,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "9999,0", + "unit": "m³", "additionalValue": "90000,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "9000,0", + "unit": "m³", }, ], }, diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr index c9f5e72ae1f..7395e2f6dc6 100644 --- a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -12,13 +12,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -34,17 +38,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), @@ -103,13 +112,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -125,17 +138,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index 7329eec7f70..02076bf5597 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '26e93f1a-c828-11ea-87d0-0242ac130003', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ista SE', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -50,7 +48,6 @@ 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ista SE', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr index 9536c5336db..8546b704d3d 100644 --- a/tests/components/ista_ecotrend/snapshots/test_util.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_get_statistics +# name: test_get_statistics[heating-None] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -11,19 +11,7 @@ }), ]) # --- -# name: test_get_statistics.1 - list([ - dict({ - 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 113.0, - }), - dict({ - 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 38.0, - }), - ]) -# --- -# name: test_get_statistics.2 +# name: test_get_statistics[heating-costs] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -35,7 +23,19 @@ }), ]) # --- -# name: test_get_statistics.3 +# name: test_get_statistics[heating-energy] + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 113.0, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 38.0, + }), + ]) +# --- +# name: test_get_statistics[warmwater-None] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -47,7 +47,19 @@ }), ]) # --- -# name: test_get_statistics.4 +# name: test_get_statistics[warmwater-costs] + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + ]) +# --- +# name: test_get_statistics[warmwater-energy] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -59,19 +71,7 @@ }), ]) # --- -# name: test_get_statistics.5 - list([ - dict({ - 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 7, - }), - dict({ - 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 7, - }), - ]) -# --- -# name: test_get_statistics.6 +# name: test_get_statistics[water-None] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -83,11 +83,7 @@ }), ]) # --- -# name: test_get_statistics.7 - list([ - ]) -# --- -# name: test_get_statistics.8 +# name: test_get_statistics[water-costs] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -99,39 +95,56 @@ }), ]) # --- -# name: test_get_values_by_type +# name: test_get_statistics[water-energy] + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 6.8, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 5.0, + }), + ]) +# --- +# name: test_get_values_by_type[heating] dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }) # --- -# name: test_get_values_by_type.1 - dict({ - 'additionalValue': '57,0', - 'type': 'warmwater', - 'value': '1,0', - }) -# --- -# name: test_get_values_by_type.2 - dict({ - 'type': 'water', - 'value': '5,0', - }) -# --- -# name: test_get_values_by_type.3 +# name: test_get_values_by_type[heating].1 dict({ 'type': 'heating', 'value': 21, }) # --- -# name: test_get_values_by_type.4 +# name: test_get_values_by_type[warmwater] + dict({ + 'additionalUnit': 'kWh', + 'additionalValue': '57,0', + 'type': 'warmwater', + 'unit': 'm³', + 'value': '1,0', + }) +# --- +# name: test_get_values_by_type[warmwater].1 dict({ 'type': 'warmwater', 'value': 7, }) # --- -# name: test_get_values_by_type.5 +# name: test_get_values_by_type[water] + dict({ + 'type': 'water', + 'unit': 'm³', + 'value': '5,0', + }) +# --- +# name: test_get_values_by_type[water].1 dict({ 'type': 'water', 'value': 3, diff --git a/tests/components/ista_ecotrend/test_sensor.py b/tests/components/ista_ecotrend/test_sensor.py index 82a15872b59..fb1cc63f084 100644 --- a/tests/components/ista_ecotrend/test_sensor.py +++ b/tests/components/ista_ecotrend/test_sensor.py @@ -10,7 +10,9 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.usefixtures("mock_ista", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "mock_ista", "recorder_mock", "entity_registry_enabled_by_default" +) async def test_setup( hass: HomeAssistant, ista_config_entry: MockConfigEntry, diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py index 616abdea8d6..f6840dcd88b 100644 --- a/tests/components/ista_ecotrend/test_util.py +++ b/tests/components/ista_ecotrend/test_util.py @@ -1,5 +1,6 @@ """Tests for the ista EcoTrend utility functions.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.ista_ecotrend.util import ( @@ -34,30 +35,43 @@ def test_last_day_of_month(snapshot: SnapshotAssertion) -> None: assert last_day_of_month(month=month + 1, year=2024) == snapshot -def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + "consumption_type", + [ + IstaConsumptionType.HEATING, + IstaConsumptionType.HOT_WATER, + IstaConsumptionType.WATER, + ], +) +def test_get_values_by_type( + snapshot: SnapshotAssertion, consumption_type: IstaConsumptionType +) -> None: """Test get_values_by_type function.""" consumptions = { "readings": [ { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "5,0", + "unit": "m³", }, ], } - assert get_values_by_type(consumptions, IstaConsumptionType.HEATING) == snapshot - assert get_values_by_type(consumptions, IstaConsumptionType.HOT_WATER) == snapshot - assert get_values_by_type(consumptions, IstaConsumptionType.WATER) == snapshot + assert get_values_by_type(consumptions, consumption_type) == snapshot costs = { "costsByEnergyType": [ @@ -76,71 +90,58 @@ def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: ], } - assert get_values_by_type(costs, IstaConsumptionType.HEATING) == snapshot - assert get_values_by_type(costs, IstaConsumptionType.HOT_WATER) == snapshot - assert get_values_by_type(costs, IstaConsumptionType.WATER) == snapshot + assert get_values_by_type(costs, consumption_type) == snapshot - assert get_values_by_type({}, IstaConsumptionType.HEATING) == {} - assert get_values_by_type({"readings": []}, IstaConsumptionType.HEATING) == {} + assert get_values_by_type({}, consumption_type) == {} + assert get_values_by_type({"readings": []}, consumption_type) == {} -def test_get_native_value() -> None: +@pytest.mark.parametrize( + ("consumption_type", "value_type", "expected_value"), + [ + (IstaConsumptionType.HEATING, None, 35), + (IstaConsumptionType.HOT_WATER, None, 1.0), + (IstaConsumptionType.WATER, None, 5.0), + (IstaConsumptionType.HEATING, IstaValueType.COSTS, 21), + (IstaConsumptionType.HOT_WATER, IstaValueType.COSTS, 7), + (IstaConsumptionType.WATER, IstaValueType.COSTS, 3), + (IstaConsumptionType.HEATING, IstaValueType.ENERGY, 38.0), + (IstaConsumptionType.HOT_WATER, IstaValueType.ENERGY, 57.0), + ], +) +def test_get_native_value( + consumption_type: IstaConsumptionType, + value_type: IstaValueType | None, + expected_value: float, +) -> None: """Test getting native value for sensor states.""" test_data = get_consumption_data("26e93f1a-c828-11ea-87d0-0242ac130003") - assert get_native_value(test_data, IstaConsumptionType.HEATING) == 35 - assert get_native_value(test_data, IstaConsumptionType.HOT_WATER) == 1.0 - assert get_native_value(test_data, IstaConsumptionType.WATER) == 5.0 - - assert ( - get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) - == 21 - ) - assert ( - get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.COSTS) - == 7 - ) - assert ( - get_native_value(test_data, IstaConsumptionType.WATER, IstaValueType.COSTS) == 3 - ) - - assert ( - get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.ENERGY) - == 38.0 - ) - assert ( - get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.ENERGY) - == 57.0 - ) + assert get_native_value(test_data, consumption_type, value_type) == expected_value no_data = {"consumptions": None, "costs": None} - assert get_native_value(no_data, IstaConsumptionType.HEATING) is None - assert ( - get_native_value(no_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) - is None - ) + assert get_native_value(no_data, consumption_type, value_type) is None -def test_get_statistics(snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + "value_type", + [None, IstaValueType.ENERGY, IstaValueType.COSTS], +) +@pytest.mark.parametrize( + "consumption_type", + [ + IstaConsumptionType.HEATING, + IstaConsumptionType.HOT_WATER, + IstaConsumptionType.WATER, + ], +) +def test_get_statistics( + snapshot: SnapshotAssertion, + value_type: IstaValueType | None, + consumption_type: IstaConsumptionType, +) -> None: """Test get_statistics function.""" test_data = get_consumption_data("26e93f1a-c828-11ea-87d0-0242ac130003") - for consumption_type in IstaConsumptionType: - assert get_statistics(test_data, consumption_type) == snapshot - assert get_statistics({"consumptions": None}, consumption_type) is None - assert ( - get_statistics(test_data, consumption_type, IstaValueType.ENERGY) - == snapshot - ) - assert ( - get_statistics( - {"consumptions": None}, consumption_type, IstaValueType.ENERGY - ) - is None - ) - assert ( - get_statistics(test_data, consumption_type, IstaValueType.COSTS) == snapshot - ) - assert ( - get_statistics({"costs": None}, consumption_type, IstaValueType.COSTS) - is None - ) + assert get_statistics(test_data, consumption_type, value_type) == snapshot + + assert get_statistics({"consumptions": None}, consumption_type, value_type) is None diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index 5093cc301a1..7582a2a6645 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -47,7 +47,7 @@ def mock_config_entry() -> MockConfigEntry: class MockVehicle: """Mock vehicle.""" - def __init__(self) -> None: + def __init__(self, is_electric_vehicle=False) -> None: """Initialize mock vehicle.""" self.license_plate = "12345678" self.make = "mock make" @@ -61,11 +61,20 @@ class MockVehicle: 2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("Asia/Jerusalem") ) self.battery_voltage = 12.0 + self.is_electric_vehicle = is_electric_vehicle + if is_electric_vehicle: + self.battery_level = 42 + self.battery_range = 150 + self.is_charging = True + else: + self.battery_level = 0 + self.battery_range = 0 + self.is_charging = False @pytest.fixture -def mock_ituran() -> Generator[AsyncMock]: - """Return a mocked PalazzettiClient.""" +def mock_ituran(request: pytest.FixtureRequest) -> Generator[AsyncMock]: + """Return a mocked Ituran.""" with ( patch( "homeassistant.components.ituran.coordinator.Ituran", @@ -79,7 +88,8 @@ def mock_ituran() -> Generator[AsyncMock]: mock_ituran = ituran.return_value mock_ituran.is_authenticated.return_value = False mock_ituran.authenticate.return_value = True - mock_ituran.get_vehicles.return_value = [MockVehicle()] + is_electric_vehicle = getattr(request, "param", False) + mock_ituran.get_vehicles.return_value = [MockVehicle(is_electric_vehicle)] type(mock_ituran).mobile_id = PropertyMock( return_value=MOCK_CONFIG_DATA[CONF_MOBILE_ID] ) diff --git a/tests/components/ituran/snapshots/test_binary_sensor.ambr b/tests/components/ituran/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..fed9f2b487c --- /dev/null +++ b/tests/components/ituran/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.mock_model_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-is_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'mock model Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_model_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index b97aef6027b..5fb786029b4 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -18,7 +18,6 @@ '12345678', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'mock make', @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '12345678', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr index 5278c657a66..a577d836b0e 100644 --- a/tests/components/ituran/snapshots/test_sensor.ambr +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -1,4 +1,415 @@ # serializer version: 1 +# name: test_ev_sensor[True][sensor.mock_model_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Address', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'address', + 'unique_id': '12345678-address', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Address', + }), + 'context': , + 'entity_id': 'sensor.mock_model_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bermuda Triangle', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'mock model Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '12345678-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'mock model Battery voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_heading-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_heading', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heading', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heading', + 'unique_id': '12345678-heading', + 'unit_of_measurement': '°', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_heading-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Heading', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.mock_model_heading', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_last_update_from_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update from vehicle', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_update_from_vehicle', + 'unique_id': '12345678-last_update_from_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_last_update_from_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'mock model Last update from vehicle', + }), + 'context': , + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-12-31T22:00:00+00:00', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': '12345678-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Mileage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_remaining_range-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_remaining_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_range', + 'unique_id': '12345678-battery_range', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_remaining_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Remaining range', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_remaining_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-speed', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'mock model Speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- # name: test_sensor[sensor.mock_model_address-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ituran/test_binary_sensor.py b/tests/components/ituran/test_binary_sensor.py new file mode 100644 index 00000000000..1eb2fca6f4c --- /dev/null +++ b/tests/components/ituran/test_binary_sensor.py @@ -0,0 +1,73 @@ +"""Test the Ituran binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyituran.exceptions import IturanApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ituran.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is marked as unavailable when we can't reach the Ituran service.""" + entities = [ + "binary_sensor.mock_model_charging", + ] + + await setup_integration(hass, mock_config_entry) + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = IturanApiError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/ituran/test_sensor.py b/tests/components/ituran/test_sensor.py index a057f59b81f..4293cf08f2d 100644 --- a/tests/components/ituran/test_sensor.py +++ b/tests/components/ituran/test_sensor.py @@ -32,13 +32,27 @@ async def test_sensor( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_availability( +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def __test_availability( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_ituran: AsyncMock, mock_config_entry: MockConfigEntry, + ev_entity_names: list[str] | None = None, ) -> None: - """Test sensor is marked as unavailable when we can't reach the Ituran service.""" entities = [ "sensor.mock_model_address", "sensor.mock_model_battery_voltage", @@ -46,6 +60,7 @@ async def test_availability( "sensor.mock_model_last_update_from_vehicle", "sensor.mock_model_mileage", "sensor.mock_model_speed", + *(ev_entity_names if ev_entity_names is not None else []), ] await setup_integration(hass, mock_config_entry) @@ -74,3 +89,32 @@ async def test_availability( state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ICE sensor is marked as unavailable when we can't reach the Ituran service.""" + await __test_availability(hass, freezer, mock_ituran, mock_config_entry) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test EV sensor is marked as unavailable when we can't reach the Ituran service.""" + ev_entities = [ + "sensor.mock_model_battery", + "sensor.mock_model_remaining_range", + ] + await __test_availability( + hass, freezer, mock_ituran, mock_config_entry, ev_entities + ) diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py index c3732714177..71088dea2ea 100644 --- a/tests/components/jellyfin/conftest.py +++ b/tests/components/jellyfin/conftest.py @@ -81,6 +81,7 @@ def mock_api() -> MagicMock: jf_api.get_item.side_effect = api_get_item_side_effect jf_api.get_media_folders.return_value = load_json_fixture("get-media-folders.json") jf_api.user_items.side_effect = api_user_items_side_effect + jf_api.search_media_items.return_value = load_json_fixture("user-items.json") return jf_api diff --git a/tests/components/jellyfin/fixtures/get-user-settings.json b/tests/components/jellyfin/fixtures/get-user-settings.json index 5e28f87d8f2..5ed59661a60 100644 --- a/tests/components/jellyfin/fixtures/get-user-settings.json +++ b/tests/components/jellyfin/fixtures/get-user-settings.json @@ -1,5 +1,5 @@ { - "Id": "string", + "Id": "USER-UUID", "ViewType": "string", "SortBy": "string", "IndexBy": "string", diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index db2b691dff0..9a8f93dc5bd 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -21,7 +21,7 @@ ], "Capabilities": { "PlayableMediaTypes": ["Video"], - "SupportedCommands": ["VolumeSet", "Mute"], + "SupportedCommands": ["VolumeSet", "Mute", "PlayMediaSource"], "SupportsMediaControl": true, "SupportsContentUploading": true, "MessageCallbackUrl": "string", diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index 9d73ee6397c..0100c7618b7 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -182,6 +182,7 @@ 'SupportedCommands': list([ 'VolumeSet', 'Mute', + 'PlayMediaSource', ]), 'SupportsContentUploading': True, 'SupportsMediaControl': True, diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index a8ffbcbf46c..fd9d3b1d773 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -23,17 +23,6 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: - """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - async def test_form( hass: HomeAssistant, mock_jellyfin: MagicMock, @@ -201,6 +190,32 @@ async def test_form_persists_device_id_on_error( } +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the case where the user tries to configure an already configured entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_reauth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 404fdc801ee..b4506f5a607 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -363,6 +363,47 @@ async def test_browse_media( ) +async def test_search_media( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin browse media.""" + client = await hass_ws_client() + + # browse root folder + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.jellyfin_device", + "media_content_id": "", + "media_content_type": "", + "search_query": "Fake Item 1", + "media_filter_classes": ["movie"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["result"] == [ + { + "title": "FOLDER", + "media_class": MediaClass.DIRECTORY.value, + "media_content_type": "string", + "media_content_id": "FOLDER-UUID", + "children_media_class": None, + "can_play": False, + "can_expand": True, + "can_search": False, + "not_shown": 0, + "thumbnail": "http://localhost/Items/21af9851-8e39-43a9-9c47-513d3b9e99fc/Images/Primary.jpg", + "children": [], + } + ] + + async def test_new_client_connected( hass: HomeAssistant, init_integration: MockConfigEntry, diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 3c8acde6e72..859cdefd9c2 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -3,6 +3,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 40, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -17,51 +26,22 @@ 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), - 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 40, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', 'diaspora': False, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'zmanim': dict({ - 'candle_lighting_offset': 40, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", - }), + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), }), @@ -77,6 +57,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': True, + 'nusach': 'sephardi', + }), 'diaspora': True, 'havdalah_offset': 0, 'language': 'en', @@ -91,51 +80,22 @@ 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), - 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', 'diaspora': True, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': True, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", - }), + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), }), @@ -151,6 +111,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -165,51 +134,22 @@ 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), - 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', 'diaspora': False, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", - }), + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), }), diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 46f5fdfcc7d..a4c9fd02be3 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -6,11 +6,8 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import async_fire_time_changed @@ -140,17 +137,3 @@ async def test_issur_melacha_sensor_update( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results[1] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert BINARY_SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - {BINARY_SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert BINARY_SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 7a8b6b8df1e..234cae2adca 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -28,19 +28,18 @@ from tests.common import MockConfigEntry async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test user config.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DIASPORA: DEFAULT_DIASPORA, CONF_LANGUAGE: DEFAULT_LANGUAGE}, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 @@ -111,7 +110,6 @@ async def test_options_reconfigure( CONF_CANDLE_LIGHT_MINUTES: DEFAULT_CANDLE_LIGHT + 1, }, ) - assert result["result"] # The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value assert config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 38a3dd12206..ab24d35f932 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -8,11 +8,7 @@ from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -569,17 +565,3 @@ async def test_sensor_does_not_update_on_time_change( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results["new_state"] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py index 4b3f31d11d4..ce5ccf2af37 100644 --- a/tests/components/jewish_calendar/test_service.py +++ b/tests/components/jewish_calendar/test_service.py @@ -4,7 +4,13 @@ import datetime as dt import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.jewish_calendar.const import ( + ATTR_AFTER_SUNSET, + ATTR_DATE, + ATTR_NUSACH, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE from homeassistant.core import HomeAssistant @@ -14,10 +20,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 3, 20), - "nusach": "sfarad", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 3, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "", id="no_blessing", @@ -25,10 +31,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "ashkenaz", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "ashkenaz", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", id="ahskenaz-hebrew", @@ -36,10 +42,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": True, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: True, }, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset", @@ -47,23 +53,23 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: False, }, "Today is the thirty-seventh day, which are five weeks and two days of the Omer", id="sefarad-english-before-sunset", ), pytest.param( dt.datetime(2025, 5, 20, 21, 0), - {"nusach": "sfarad", "language": "en"}, + {ATTR_NUSACH: "sfarad", CONF_LANGUAGE: "en"}, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset-without-date", ), pytest.param( dt.datetime(2025, 5, 20, 6, 0), - {"nusach": "sfarad"}, + {ATTR_NUSACH: "sfarad"}, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים לעומר", id="sefarad-english-before-sunset-without-date", ), diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py index cc3a7a88285..d581f230dde 100644 --- a/tests/components/justnimbus/test_config_flow.py +++ b/tests/components/justnimbus/test_config_flow.py @@ -132,7 +132,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with patch( "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", - return_value=MagicMock(), + return_value=MagicMock(api_version="1.0.0"), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py index dc0c89e8ea6..dc812af6d01 100644 --- a/tests/components/keenetic_ndms2/__init__.py +++ b/tests/components/keenetic_ndms2/__init__.py @@ -25,6 +25,12 @@ MOCK_DATA = { CONF_PORT: 23, } +MOCK_RECONFIGURE = { + CONF_USERNAME: "user1", + CONF_PASSWORD: "pass1", + CONF_PORT: 123, +} + MOCK_OPTIONS = { CONF_SCAN_INTERVAL: 15, const.CONF_CONSIDER_HOME: 150, diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 3293bd3d4da..1b86e6c265c 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -19,7 +19,14 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, ) -from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS, MOCK_SSDP_DISCOVERY_INFO +from . import ( + MOCK_DATA, + MOCK_IP, + MOCK_NAME, + MOCK_OPTIONS, + MOCK_RECONFIGURE, + MOCK_SSDP_DISCOVERY_INFO, +) from tests.common import MockConfigEntry @@ -75,6 +82,34 @@ async def test_flow_works(hass: HomeAssistant, connect) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_reconfigure(hass: HomeAssistant, connect) -> None: + """Test reconfigure flow.""" + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_RECONFIGURE, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_HOST: MOCK_IP, + **MOCK_RECONFIGURE, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index 9c9f31a2544..2bee2f1f61c 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -65,7 +65,6 @@ 'outlet_1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -75,7 +74,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -98,7 +96,6 @@ '2_ch_power_strip', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -108,7 +105,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -179,7 +175,6 @@ 'outlet_2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -189,7 +184,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -212,7 +206,6 @@ '2_ch_power_strip', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -222,7 +215,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 02ad346cd58..598b8681b11 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -15,7 +15,6 @@ from homeassistant.components.backup import ( from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -36,8 +35,7 @@ async def backup_only() -> AsyncGenerator[None]: @pytest.fixture(autouse=True) async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: - """Set up Kitchen Sink and backup integrations.""" - async_initialize_backup(hass) + """Set up Kitchen Sink integration.""" with patch("homeassistant.components.backup.is_hassio", return_value=False): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/knocki/test_config_flow.py b/tests/components/knocki/test_config_flow.py index 4affbd2a197..a82991094b2 100644 --- a/tests/components/knocki/test_config_flow.py +++ b/tests/components/knocki/test_config_flow.py @@ -20,7 +20,7 @@ from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="KNC1-W-00000214", - macaddress="aa:bb:cc:dd:ee:ff", + macaddress="aabbccddeeff", ) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 4eefe3166b5..576fce802c0 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -40,15 +40,9 @@ from homeassistant.setup import async_setup_component from . import KnxEntityGenerator -from tests.common import ( - MockConfigEntry, - async_load_json_object_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry, async_load_json_object_fixture from tests.typing import WebSocketGenerator -FIXTURE_PROJECT_DATA = load_json_object_fixture("project.json", DOMAIN) - class KNXTestKit: """Test helper for the KNX integration.""" @@ -82,6 +76,7 @@ class KNXTestKit: yaml_config: ConfigType | None = None, config_store_fixture: str | None = None, add_entry_to_hass: bool = True, + state_updater: bool = True, ) -> None: """Create the KNX integration.""" @@ -124,14 +119,24 @@ class KNXTestKit: self.mock_config_entry.add_to_hass(self.hass) knx_config = {DOMAIN: yaml_config or {}} - with patch( - "xknx.xknx.knx_interface_factory", - return_value=knx_ip_interface_mock(), - side_effect=fish_xknx, + with ( + patch( + "xknx.xknx.knx_interface_factory", + return_value=knx_ip_interface_mock(), + side_effect=fish_xknx, + ), ): + state_updater_patcher = patch( + "xknx.xknx.StateUpdater.register_remote_value" + ) + if not state_updater: + state_updater_patcher.start() + await async_setup_component(self.hass, DOMAIN, knx_config) await self.hass.async_block_till_done() + state_updater_patcher.stop() + ######################## # Telegram counter tests ######################## @@ -315,6 +320,9 @@ def mock_config_entry() -> MockConfigEntry: title="KNX", domain=DOMAIN, data={ + # homeassistant.components.knx.config_flow.DEFAULT_ENTRY_DATA has additional keys + # there are installations out there without these keys so we test with legacy data + # to ensure backwards compatibility (local_ip, telegram_log_size) CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_RATE_LIMIT: CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, @@ -338,11 +346,19 @@ async def knx( @pytest.fixture -def load_knxproj(hass_storage: dict[str, Any]) -> None: +async def project_data(hass: HomeAssistant) -> dict[str, Any]: + """Return the fixture project data.""" + return await async_load_json_object_fixture(hass, "project.json", DOMAIN) + + +@pytest.fixture +async def load_knxproj( + project_data: dict[str, Any], hass_storage: dict[str, Any] +) -> None: """Mock KNX project data.""" hass_storage[KNX_PROJECT_STORAGE_KEY] = { "version": 1, - "data": FIXTURE_PROJECT_DATA, + "data": project_data, } diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 427867cff8c..2b6e5887f9e 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_cover.json b/tests/components/knx/fixtures/config_store_cover.json index 6ec8dcc90fa..8f89a4ee47b 100644 --- a/tests/components/knx/fixtures/config_store_cover.json +++ b/tests/components/knx/fixtures/config_store_cover.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json new file mode 100644 index 00000000000..61ec1044746 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light.json @@ -0,0 +1,142 @@ +{ + "version": 2, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + } + } + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store_light_switch.json b/tests/components/knx/fixtures/config_store_light_switch.json index 5eabcfa87f9..0b14535bbea 100644 --- a/tests/components/knx/fixtures/config_store_light_switch.json +++ b/tests/components/knx/fixtures/config_store_light_switch.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { @@ -33,7 +33,6 @@ "knx": { "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/21", "state": "1/0/21", diff --git a/tests/components/knx/fixtures/config_store_light_v1.json b/tests/components/knx/fixtures/config_store_light_v1.json new file mode 100644 index 00000000000..3e049e145f2 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light_v1.json @@ -0,0 +1,140 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "individual", + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "hsv", + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 6ebe8192f69..6457d099eb2 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -48,7 +48,7 @@ from homeassistant.components.knx.const import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, get_fixture_path @@ -174,27 +174,27 @@ async def test_routing_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -227,19 +227,19 @@ async def test_routing_setup_advanced( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} # invalid user input result_invalid_input = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_MCAST_GRP: "10.1.2.3", # no valid multicast group CONF_KNX_MCAST_PORT: 3675, @@ -257,8 +257,8 @@ async def test_routing_setup_advanced( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, @@ -266,9 +266,9 @@ async def test_routing_setup_advanced( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -297,18 +297,18 @@ async def test_routing_secure_manual_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -316,19 +316,19 @@ async def test_routing_secure_manual_setup( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_routing_manual"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_routing_manual" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_routing_manual" + assert not result["errors"] result_invalid_key1 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "xxaacc44bbaacc44bbaacc44bbaaccyy", # invalid hex string CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -339,7 +339,7 @@ async def test_routing_secure_manual_setup( assert result_invalid_key1["errors"] == {"backbone_key": "invalid_backbone_key"} result_invalid_key2 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44", # invalid length CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -386,18 +386,18 @@ async def test_routing_secure_keyfile( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -405,20 +405,20 @@ async def test_routing_secure_keyfile( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): routing_secure_knxkeys = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", @@ -532,15 +532,15 @@ async def test_tunneling_setup_manual( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} with patch( "homeassistant.components.knx.config_flow.request_description", @@ -552,13 +552,13 @@ async def test_tunneling_setup_manual( ), ), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == title - assert result3["data"] == config_entry_data + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == title + assert result["data"] == config_entry_data knx_setup.assert_called_once() @@ -724,19 +724,19 @@ async def test_tunneling_setup_for_local_ip( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} # invalid host ip address result_invalid_host = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: DEFAULT_MCAST_GRP, # multicast addresses are invalid @@ -752,7 +752,7 @@ async def test_tunneling_setup_for_local_ip( } # invalid local ip address result_invalid_local = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -768,8 +768,8 @@ async def test_tunneling_setup_for_local_ip( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -777,9 +777,9 @@ async def test_tunneling_setup_for_local_ip( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Tunneling UDP @ 192.168.0.2" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Tunneling UDP @ 192.168.0.2" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -1008,15 +1008,15 @@ async def test_form_with_automatic_connection_handling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { # don't use **DEFAULT_ENTRY_DATA here to check for correct usage of defaults CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", @@ -1032,7 +1032,9 @@ async def test_form_with_automatic_connection_handling( knx_setup.assert_called_once() -async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: +async def _get_menu_step_secure_tunnel( + hass: HomeAssistant, +) -> config_entries.ConfigFlowResult: """Return flow in secure_tunnel menu step.""" gateway = _gateway_descriptor( "192.168.0.1", @@ -1050,23 +1052,23 @@ async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" - return result3 + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" + return result @patch( @@ -1099,24 +1101,24 @@ async def test_get_secure_menu_step_manual_tunnelling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] manual_tunnel_flow = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_GATEWAY: OPTION_MANUAL_TUNNEL, }, ) - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( manual_tunnel_flow["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1124,8 +1126,8 @@ async def test_get_secure_menu_step_manual_tunnelling( CONF_PORT: 3675, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: @@ -1269,52 +1271,51 @@ async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) assert secure_knxkeys["errors"] == {"base": "keyfile_no_tunnel_for_host"} -async def test_options_flow_connection_type( +async def test_reconfigure_flow_connection_type( hass: HomeAssistant, knx, mock_config_entry: MockConfigEntry ) -> None: - """Test options flow changing interface.""" - # run one option flow test with a set up integration (knx fixture) + """Test reconfigure flow changing interface.""" + # run one flow test with a set up integration (knx fixture) # instead of mocking async_setup_entry (knx_setup fixture) to test # usage of the already running XKNX instance for gateway scanner gateway = _gateway_descriptor("192.168.0.1", 3675) await knx.setup_integration() - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + menu_step = await knx.mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ CONF_KNX_GATEWAY: str(gateway), }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3["data"] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_HOST: "192.168.0.1", CONF_PORT: 3675, - CONF_KNX_LOCAL_IP: None, CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_RATE_LIMIT: 0, @@ -1324,14 +1325,13 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, - CONF_KNX_TELEGRAM_LOG_SIZE: 1000, } -async def test_options_flow_secure_manual_to_keyfile( +async def test_reconfigure_flow_secure_manual_to_keyfile( hass: HomeAssistant, knx_setup ) -> None: - """Test options flow changing secure credential source.""" + """Test reconfigure flow changing secure credential source.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1359,46 +1359,47 @@ async def test_options_flow_secure_manual_to_keyfile( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): - secure_knxkeys = await hass.config_entries.options.async_configure( - result4["flow_id"], + secure_knxkeys = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "test", @@ -1407,12 +1408,13 @@ async def test_options_flow_secure_manual_to_keyfile( assert result["type"] is FlowResultType.FORM assert secure_knxkeys["step_id"] == "knxkeys_tunnel_select" assert not result["errors"] - secure_knxkeys = await hass.config_entries.options.async_configure( + secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys["flow_id"], {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"}, ) - assert secure_knxkeys["type"] is FlowResultType.CREATE_ENTRY + assert secure_knxkeys["type"] is FlowResultType.ABORT + assert secure_knxkeys["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1433,8 +1435,8 @@ async def test_options_flow_secure_manual_to_keyfile( knx_setup.assert_called_once() -async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: - """Test options flow changing routing settings.""" +async def test_reconfigure_flow_routing(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow changing routing settings.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1446,36 +1448,38 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: gateway = _gateway_descriptor("192.168.0.1", 3676) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {} - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, @@ -1491,43 +1495,8 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_communication_settings( - hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry -) -> None: - """Test options flow changing communication settings.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - - result = await hass.config_entries.options.async_configure( - menu_step["flow_id"], - {"next_step_id": "communication_settings"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "communication_settings" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") - assert mock_config_entry.data == { - **DEFAULT_ENTRY_DATA, - CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - } - knx_setup.assert_called_once() - - -async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: - """Test options flow updating keyfile when tunnel endpoint is already configured.""" +async def test_reconfigure_update_keyfile(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow updating keyfile when tunnel endpoint is already configured.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1549,9 +1518,10 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1559,15 +1529,15 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1578,8 +1548,8 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: - """Test options flow uploading a keyfile for the first time.""" +async def test_reconfigure_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow uploading a keyfile for the first time.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, @@ -1596,9 +1566,10 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1606,7 +1577,7 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, @@ -1614,17 +1585,17 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "knxkeys_tunnel_select" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "knxkeys_tunnel_select" - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1637,3 +1608,35 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, } knx_setup.assert_called_once() + + +async def test_options_communication_settings( + hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow changing communication settings.""" + initial_data = dict(mock_config_entry.data) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "communication_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert not result.get("data") + assert initial_data != dict(mock_config_entry.data) + assert mock_config_entry.data == { + **initial_data, + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + } + knx_setup.assert_called_once() diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index aee0a4036ff..3e902f8f402 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -14,6 +14,7 @@ from homeassistant.helpers import entity_registry as er from . import KnxEntityGenerator from .conftest import KNXTestKit +from tests.common import async_load_json_object_fixture from tests.typing import WebSocketGenerator @@ -379,6 +380,7 @@ async def test_validate_entity( await knx.setup_integration() client = await hass_ws_client(hass) + # valid data await client.send_json_auto_id( { "type": "knx/validate_entity", @@ -410,3 +412,49 @@ async def test_validate_entity( assert res["result"]["errors"][0]["path"] == ["data", "knx", "ga_switch", "write"] assert res["result"]["errors"][0]["error_message"] == "required key not provided" assert res["result"]["error_base"].startswith("required key not provided") + + # invalid group_select data + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.LIGHT, + "data": { + "entity": {"name": "test_name"}, + "knx": { + "color": { + "ga_red_brightness": {"write": "1/2/3"}, + "ga_green_brightness": {"write": "1/2/4"}, + # ga_blue_brightness is missing - which is required + } + }, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + # This shall test that a required key of the second GroupSelect schema is missing + # and not yield the "extra keys not allowed" error of the first GroupSelect Schema + assert res["result"]["errors"][0]["path"] == [ + "data", + "knx", + "color", + "ga_blue_brightness", + ] + assert res["result"]["errors"][0]["error_message"] == "required key not provided" + assert res["result"]["error_base"].startswith("required key not provided") + + +async def test_migration_1_to_2( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +) -> None: + """Test migration from schema 1 to schema 2.""" + await knx.setup_integration( + config_store_fixture="config_store_light_v1.json", state_updater=False + ) + new_data = await async_load_json_object_fixture( + hass, "config_store_light.json", "knx" + ) + assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index e4a208906c6..124ce60e475 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -301,6 +301,7 @@ async def test_get_trigger_capabilities( { "name": "destination", "optional": True, + "required": False, "selector": { "select": { "custom_value": True, @@ -314,6 +315,7 @@ async def test_get_trigger_capabilities( { "name": "group_value_write", "optional": True, + "required": False, "default": True, "selector": { "boolean": {}, @@ -322,6 +324,7 @@ async def test_get_trigger_capabilities( { "name": "group_value_response", "optional": True, + "required": False, "default": True, "selector": { "boolean": {}, @@ -330,6 +333,7 @@ async def test_get_trigger_capabilities( { "name": "group_value_read", "optional": True, + "required": False, "default": True, "selector": { "boolean": {}, @@ -338,6 +342,7 @@ async def test_get_trigger_capabilities( { "name": "incoming", "optional": True, + "required": False, "default": True, "selector": { "boolean": {}, @@ -346,6 +351,7 @@ async def test_get_trigger_capabilities( { "name": "outgoing", "optional": True, + "required": False, "default": True, "selector": { "boolean": {}, diff --git a/tests/components/knx/test_events.py b/tests/components/knx/test_events.py index 2228781ba89..a40109d167e 100644 --- a/tests/components/knx/test_events.py +++ b/tests/components/knx/test_events.py @@ -4,7 +4,8 @@ import logging import pytest -from homeassistant.components.knx import CONF_EVENT, CONF_TYPE, KNX_ADDRESS +from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.const import CONF_EVENT, CONF_TYPE from homeassistant.core import HomeAssistant from .conftest import KNXTestKit diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index f7a3f4e94f2..331678f0683 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -6,7 +6,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS +from homeassistant.components.knx.const import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import ( CONF_ATTRIBUTE, diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index fb0246763a4..5edf150ef4f 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1182,7 +1182,6 @@ async def test_light_ui_create( entity_data={"name": "test"}, knx_data={ "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1223,7 +1222,6 @@ async def test_light_ui_color_temp( "write": "3/3/3", "dpt": color_temp_mode, }, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1257,7 +1255,6 @@ async def test_light_ui_multi_mode( knx_data={ "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/1", "passive": [], @@ -1275,11 +1272,13 @@ async def test_light_ui_multi_mode( "state": "0/6/3", "passive": [], }, - "ga_color": { - "write": "0/6/4", - "dpt": "251.600", - "state": "0/6/5", - "passive": [], + "color": { + "ga_color": { + "write": "0/6/4", + "dpt": "251.600", + "state": "0/6/5", + "passive": [], + }, }, }, ) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 7054d415ee9..5c0f002a541 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -11,7 +11,7 @@ from homeassistant.components.knx.schema import SwitchSchema from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from .conftest import FIXTURE_PROJECT_DATA, KNXTestKit +from .conftest import KNXTestKit from tests.typing import WebSocketGenerator @@ -22,7 +22,7 @@ async def test_knx_info_command( """Test knx/info command.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/info"}) + await client.send_json_auto_id({"type": "knx/info"}) res = await client.receive_json() assert res["success"], res @@ -32,16 +32,16 @@ async def test_knx_info_command( assert res["result"]["project"] is None +@pytest.mark.usefixtures("load_knxproj") async def test_knx_info_command_with_project( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, - load_knxproj: None, ) -> None: """Test knx/info command with loaded project.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/info"}) + await client.send_json_auto_id({"type": "knx/info"}) res = await client.receive_json() assert res["success"], res @@ -59,19 +59,18 @@ async def test_knx_project_file_process( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], + project_data: dict[str, Any], ) -> None: """Test knx/project_file_process command for storing and loading new data.""" _file_id = "1234" _password = "pw-test" - _parse_result = FIXTURE_PROJECT_DATA await knx.setup_integration() client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": "knx/project_file_process", "file_id": _file_id, "password": _password, @@ -81,7 +80,7 @@ async def test_knx_project_file_process( patch( "homeassistant.components.knx.project.process_uploaded_file", ) as file_upload_mock, - patch("xknxproject.XKNXProj.parse", return_value=_parse_result) as parse_mock, + patch("xknxproject.XKNXProj.parse", return_value=project_data) as parse_mock, ): file_upload_mock.return_value.__enter__.return_value = "" res = await client.receive_json() @@ -91,7 +90,7 @@ async def test_knx_project_file_process( assert res["success"], res assert hass.data[KNX_MODULE_KEY].project.loaded - assert hass_storage[KNX_PROJECT_STORAGE_KEY]["data"] == _parse_result + assert hass_storage[KNX_PROJECT_STORAGE_KEY]["data"] == project_data async def test_knx_project_file_process_error( @@ -104,9 +103,8 @@ async def test_knx_project_file_process_error( client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": "knx/project_file_process", "file_id": "1234", "password": "", @@ -126,11 +124,11 @@ async def test_knx_project_file_process_error( assert not hass.data[KNX_MODULE_KEY].project.loaded +@pytest.mark.usefixtures("load_knxproj") async def test_knx_project_file_remove( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, - load_knxproj: None, hass_storage: dict[str, Any], ) -> None: """Test knx/project_file_remove command.""" @@ -139,7 +137,7 @@ async def test_knx_project_file_remove( client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json({"id": 6, "type": "knx/project_file_remove"}) + await client.send_json_auto_id({"type": "knx/project_file_remove"}) res = await client.receive_json() assert res["success"], res @@ -147,22 +145,23 @@ async def test_knx_project_file_remove( assert not hass_storage.get(KNX_PROJECT_STORAGE_KEY) +@pytest.mark.usefixtures("load_knxproj") async def test_knx_get_project( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, - load_knxproj: None, + project_data: dict[str, Any], ) -> None: """Test retrieval of kxnproject from store.""" await knx.setup_integration() client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json({"id": 3, "type": "knx/get_knx_project"}) + await client.send_json_auto_id({"type": "knx/get_knx_project"}) res = await client.receive_json() assert res["success"], res assert res["result"]["project_loaded"] is True - assert res["result"]["knxproject"] == FIXTURE_PROJECT_DATA + assert res["result"]["knxproject"] == project_data async def test_knx_group_monitor_info_command( @@ -172,7 +171,7 @@ async def test_knx_group_monitor_info_command( await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + await client.send_json_auto_id({"type": "knx/group_monitor_info"}) res = await client.receive_json() assert res["success"], res @@ -234,7 +233,7 @@ async def test_knx_subscribe_telegrams_command_recent_telegrams( # connect websocket after telegrams have been sent client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + await client.send_json_auto_id({"type": "knx/group_monitor_info"}) res = await client.receive_json() assert res["success"], res assert res["result"]["project_loaded"] is False @@ -272,7 +271,7 @@ async def test_knx_subscribe_telegrams_command_no_project( } ) client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + await client.send_json_auto_id({"type": "knx/subscribe_telegrams"}) res = await client.receive_json() assert res["success"], res @@ -340,7 +339,7 @@ async def test_knx_subscribe_telegrams_command_project( """Test knx/subscribe_telegrams command with project data.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + await client.send_json_auto_id({"type": "knx/subscribe_telegrams"}) res = await client.receive_json() assert res["success"], res diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index acce8ebed7a..bedcea4ddc2 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -26,6 +26,21 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_installer_config_entry() -> MockConfigEntry: + """Return a mocked ConfigEntry for testing with installer login.""" + return MockConfigEntry( + entry_id="2ab8dd92a62787ddfe213a67e09406bd", + title="scb", + domain="kostal_plenticore", + data={ + "host": "192.168.1.2", + "password": "secret_password", + "service_code": "12345", + }, + ) + + @pytest.fixture def mock_plenticore() -> Generator[Plenticore]: """Set up a Plenticore mock with some default values.""" diff --git a/tests/components/kostal_plenticore/test_switch.py b/tests/components/kostal_plenticore/test_switch.py new file mode 100644 index 00000000000..0dd4c958fd5 --- /dev/null +++ b/tests/components/kostal_plenticore/test_switch.py @@ -0,0 +1,69 @@ +"""Test the Kostal Plenticore Solar Inverter switch platform.""" + +from pykoplenti import SettingsData + +from homeassistant.components.kostal_plenticore.coordinator import Plenticore +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_installer_setting_not_available( + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the manual charge setting is not available when not using the installer login.""" + + mock_plenticore.client.get_settings.return_value = { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not entity_registry.async_is_registered("switch.scb_battery_manual_charge") + + +async def test_installer_setting_available( + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_installer_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the manual charge setting is available when using the installer login.""" + + mock_plenticore.client.get_settings.return_value = { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + + mock_installer_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_installer_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_is_registered("switch.scb_battery_manual_charge") diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index bde60579af7..9521f98f523 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -40,9 +40,7 @@ def mock_ble_device() -> Generator[MagicMock]: """Mock BLEDevice.""" with patch( "homeassistant.components.kulersky.async_ble_device_from_address", - return_value=BLEDevice( - address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={} - ), + return_value=BLEDevice(address="AA:BB:CC:11:22:33", name="Bedroom", details={}), ) as ble_device: yield ble_device diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index ccfea1243bc..ad1378a6dc1 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -34,7 +34,7 @@ def mock_config_entry( version=3, data=USER_INPUT | { - CONF_ADDRESS: "00:00:00:00:00:00", + CONF_ADDRESS: "000000000000", CONF_TOKEN: "token", }, unique_id=mock_lamarzocco.serial_number, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index 18b2fd0fbc3..bdebd35d6dd 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -25,7 +25,6 @@ 'GS012345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'La Marzocco', @@ -35,7 +34,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GS012345', - 'suggested_area': None, 'sw_version': 'v1.17', 'via_device_id': None, }) diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index eea4616d0ff..3dd1ff9b665 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -94,7 +94,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.gs012345_last_cleaning_time-entry] diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 38cdc10d8ab..e50707f71af 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -422,7 +422,7 @@ async def test_dhcp_discovery( data=DhcpServiceInfo( ip="192.168.1.42", hostname=mock_lamarzocco.serial_number, - macaddress="aa:bb:cc:dd:ee:ff", + macaddress="aabbccddeeff", ), ) @@ -436,7 +436,7 @@ async def test_dhcp_discovery( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { **USER_INPUT, - CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_ADDRESS: "aabbccddeeff", CONF_TOKEN: None, } @@ -453,7 +453,7 @@ async def test_dhcp_discovery_abort_on_hostname_changed( data=DhcpServiceInfo( ip="192.168.1.42", hostname="custom_name", - macaddress="00:00:00:00:00:00", + macaddress="000000000000", ), ) assert result["type"] is FlowResultType.ABORT @@ -475,14 +475,14 @@ async def test_dhcp_already_configured_and_update( data=DhcpServiceInfo( ip="192.168.1.42", hostname=mock_lamarzocco.serial_number, - macaddress="aa:bb:cc:dd:ee:ff", + macaddress="aabbccddeeff", ), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_ADDRESS] != old_address - assert mock_config_entry.data[CONF_ADDRESS] == "aa:bb:cc:dd:ee:ff" + assert mock_config_entry.data[CONF_ADDRESS] == "aabbccddeeff" async def test_options_flow( diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 183d3f2daa6..dee2fa0b79c 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,11 +2,11 @@ from unittest.mock import MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import MachineState, ModelName, WidgetType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -52,3 +52,27 @@ async def test_steam_ready_entity_for_all_machines( entry = entity_registry.async_get(state.entity_id) assert entry + + +async def test_sensors_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + SWITCHES_UNAVAILABLE = ( + ("sensor.gs012345_steam_boiler_ready_time", True), + ("sensor.gs012345_coffee_boiler_ready_time", True), + ("sensor.gs012345_total_coffees_made", False), + ) + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): + await async_init_integration(hass, mock_config_entry) + + for sensor, available in SWITCHES_UNAVAILABLE: + state = hass.states.get(sensor) + assert state + assert (state.state == STATE_UNAVAILABLE) == available diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 0f1c4fd6ebb..c715c23b78f 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import MagicMock, patch -from pylamarzocco.const import SmartStandByType +from pylamarzocco.const import MachineState, SmartStandByType, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -197,3 +197,25 @@ async def test_switch_exceptions( blocking=True, ) assert exc_info.value.translation_key == "auto_on_off_error" + + +async def test_switches_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]): + await async_init_integration(hass, mock_config_entry) + + switches = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for switch in switches: + state = hass.states.get(switch.entity_id) + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 5ded11d619a..c0a52821d5a 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -187,14 +187,6 @@ "transition": 10.0 } }, - { - "address": [0, 7, false], - "name": "Sensor_LockRegulator1", - "domain": "binary_sensor", - "domain_data": { - "source": "R1VARSETPOINT" - } - }, { "address": [0, 7, false], "name": "Binary_Sensor1", @@ -203,14 +195,6 @@ "source": "BINSENSOR1" } }, - { - "address": [0, 7, false], - "name": "Sensor_KeyLock", - "domain": "binary_sensor", - "domain_data": { - "source": "A5" - } - }, { "address": [0, 7, false], "name": "Sensor_Var1", diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index d1a76b98bf1..1317150b19e 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -47,99 +47,3 @@ 'state': 'unknown', }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.testmodule_sensor_keylock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensor_KeyLock', - 'platform': 'lcn', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk_json-m000007-a5', - 'unit_of_measurement': None, - }) -# --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'TestModule Sensor_KeyLock', - }), - 'context': , - 'entity_id': 'binary_sensor.testmodule_sensor_keylock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.testmodule_sensor_lockregulator1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensor_LockRegulator1', - 'platform': 'lcn', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'TestModule Sensor_LockRegulator1', - }), - 'context': , - 'entity_id': 'binary_sensor.testmodule_sensor_lockregulator1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index b9362dcd242..a4712459e78 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -2,29 +2,20 @@ from unittest.mock import patch -from pypck.inputs import ModStatusBinSensors, ModStatusKeyLocks, ModStatusVar +from pypck.inputs import ModStatusBinSensors from pypck.lcn_addr import LcnAddr -from pypck.lcn_defs import Var, VarValue -import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.lcn import DOMAIN from homeassistant.components.lcn.helpers import get_device_connection -from homeassistant.components.script import scripts_with_entity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import MockConfigEntry, init_integration from tests.common import snapshot_platform -BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.testmodule_sensor_lockregulator1" BINARY_SENSOR_SENSOR1 = "binary_sensor.testmodule_binary_sensor1" -BINARY_SENSOR_KEYLOCK = "binary_sensor.testmodule_sensor_keylock" async def test_setup_lcn_binary_sensor( @@ -40,35 +31,6 @@ async def test_setup_lcn_binary_sensor( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_pushed_lock_setpoint_status_change( - hass: HomeAssistant, - entry: MockConfigEntry, -) -> None: - """Test the lock setpoint sensor changes its state on status received.""" - await init_integration(hass, entry) - - device_connection = get_device_connection(hass, (0, 7, False), entry) - address = LcnAddr(0, 7, False) - - # push status lock setpoint - inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x8000)) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) - assert state is not None - assert state.state == STATE_ON - - # push status unlock setpoint - inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x7FFF)) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) - assert state is not None - assert state.state == STATE_OFF - - async def test_pushed_binsensor_status_change( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -99,94 +61,9 @@ async def test_pushed_binsensor_status_change( assert state.state == STATE_ON -async def test_pushed_keylock_status_change( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test the keylock sensor changes its state on status received.""" - await init_integration(hass, entry) - - device_connection = get_device_connection(hass, (0, 7, False), entry) - address = LcnAddr(0, 7, False) - states = [[False] * 8 for i in range(4)] - - # push status keylock "off" - inp = ModStatusKeyLocks(address, states) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_KEYLOCK) - assert state is not None - assert state.state == STATE_OFF - - # push status keylock "on" - states[0][4] = True - inp = ModStatusKeyLocks(address, states) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_KEYLOCK) - assert state is not None - assert state.state == STATE_ON - - async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the binary sensor is removed when the config entry is unloaded.""" await init_integration(hass, entry) await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get(BINARY_SENSOR_LOCKREGULATOR1).state == STATE_UNAVAILABLE assert hass.states.get(BINARY_SENSOR_SENSOR1).state == STATE_UNAVAILABLE - assert hass.states.get(BINARY_SENSOR_KEYLOCK).state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize( - "entity_id", - [ - "binary_sensor.testmodule_sensor_lockregulator1", - "binary_sensor.testmodule_sensor_keylock", - ], -) -async def test_create_issue( - hass: HomeAssistant, - service_calls: list[ServiceCall], - issue_registry: ir.IssueRegistry, - entry: MockConfigEntry, - entity_id, -) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": {"action": "test.automation"}, - } - }, - ) - - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": { - "condition": "state", - "entity_id": entity_id, - "state": STATE_ON, - } - } - } - }, - ) - - await init_integration(hass, entry) - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert issue_registry.async_get_issue( - DOMAIN, f"deprecated_binary_sensor_{entity_id}" - ) diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 94eb96591e2..9f134a0c203 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -349,7 +349,15 @@ async def test_get_transponder_trigger_capabilities( assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ) == [{"name": "code", "optional": True, "type": "string", "lower": True}] + ) == [ + { + "name": "code", + "optional": True, + "required": False, + "type": "string", + "lower": True, + } + ] async def test_get_fingerprint_trigger_capabilities( @@ -373,7 +381,15 @@ async def test_get_fingerprint_trigger_capabilities( assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ) == [{"name": "code", "optional": True, "type": "string", "lower": True}] + ) == [ + { + "name": "code", + "optional": True, + "required": False, + "type": "string", + "lower": True, + } + ] async def test_get_transmitter_trigger_capabilities( @@ -398,13 +414,32 @@ async def test_get_transmitter_trigger_capabilities( assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer ) == [ - {"name": "code", "type": "string", "optional": True, "lower": True}, - {"name": "level", "type": "integer", "optional": True, "valueMin": 0}, - {"name": "key", "type": "integer", "optional": True, "valueMin": 0}, + { + "name": "code", + "type": "string", + "optional": True, + "required": False, + "lower": True, + }, + { + "name": "level", + "type": "integer", + "optional": True, + "required": False, + "valueMin": 0, + }, + { + "name": "key", + "type": "integer", + "optional": True, + "required": False, + "valueMin": 0, + }, { "name": "action", "type": "select", "optional": True, + "required": False, "options": [("hit", "hit"), ("make", "make"), ("break", "break")], }, ] @@ -436,6 +471,7 @@ async def test_get_send_keys_trigger_capabilities( "name": "key", "type": "select", "optional": True, + "required": False, "options": [(send_key.lower(), send_key.lower()) for send_key in SENDKEYS], }, { @@ -445,6 +481,7 @@ async def test_get_send_keys_trigger_capabilities( (key_action.lower(), key_action.lower()) for key_action in KEY_ACTIONS ], "optional": True, + "required": False, }, ] diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 00c2341631e..b13e18bbbd1 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -51,9 +51,9 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No """Test the output light turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: + with patch.object(MockModuleConnection, "toggle_output") as toggle_output: # command failed - dim_output.return_value = False + toggle_output.return_value = False await hass.services.async_call( DOMAIN_LIGHT, @@ -62,15 +62,15 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No blocking=True, ) - dim_output.assert_awaited_with(0, 100, 9) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_ON # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True + toggle_output.reset_mock(return_value=True) + toggle_output.return_value = True await hass.services.async_call( DOMAIN_LIGHT, @@ -79,7 +79,7 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No blocking=True, ) - dim_output.assert_awaited_with(0, 100, 9) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None @@ -117,12 +117,16 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N """Test the output light turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: - state = hass.states.get(LIGHT_OUTPUT1) - state.state = STATE_ON + with patch.object(MockModuleConnection, "toggle_output") as toggle_output: + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, + blocking=True, + ) # command failed - dim_output.return_value = False + toggle_output.return_value = False await hass.services.async_call( DOMAIN_LIGHT, @@ -131,15 +135,15 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N blocking=True, ) - dim_output.assert_awaited_with(0, 0, 9) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_OFF # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True + toggle_output.reset_mock(return_value=True) + toggle_output.return_value = True await hass.services.async_call( DOMAIN_LIGHT, @@ -148,36 +152,7 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N blocking=True, ) - dim_output.assert_awaited_with(0, 0, 9) - - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state == STATE_OFF - - -async def test_output_turn_off_with_attributes( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test the output light turns off.""" - await init_integration(hass, entry) - - with patch.object(MockModuleConnection, "dim_output") as dim_output: - dim_output.return_value = True - - state = hass.states.get(LIGHT_OUTPUT1) - state.state = STATE_ON - - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: LIGHT_OUTPUT1, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - - dim_output.assert_awaited_with(0, 0, 6) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None @@ -288,7 +263,7 @@ async def test_pushed_output_status_change( state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 127 + assert state.attributes[ATTR_BRIGHTNESS] == 128 # push status "off" inp = ModStatusOutput(address, 0, 0) diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index cdc8e9671c0..46ede8959ff 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -30,6 +30,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component from .conftest import ( @@ -134,6 +135,23 @@ async def test_service_relays( control_relays.assert_awaited_with(relay_states) + # wrong states string + with ( + patch.object(MockModuleConnection, "control_relays") as control_relays, + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + LcnService.RELAYS, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_STATE: "0011TT--00", + }, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_length_of_states_string" + async def test_service_led( hass: HomeAssistant, @@ -328,7 +346,7 @@ async def test_service_send_keys_hit_deferred( patch.object( MockModuleConnection, "send_keys_hit_deferred" ) as send_keys_hit_deferred, - pytest.raises(ValueError), + pytest.raises(ServiceValidationError) as exc_info, ): await hass.services.async_call( DOMAIN, @@ -342,6 +360,8 @@ async def test_service_send_keys_hit_deferred( }, blocking=True, ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_send_keys_action" async def test_service_lock_keys( @@ -369,6 +389,24 @@ async def test_service_lock_keys( lock_keys.assert_awaited_with(0, lock_states) + # wrong states string + with ( + patch.object(MockModuleConnection, "lock_keys") as lock_keys, + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + LcnService.LOCK_KEYS, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_TABLE: "a", + CONF_STATE: "0011TT--00", + }, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_length_of_states_string" + async def test_service_lock_keys_tab_a_temporary( hass: HomeAssistant, @@ -406,7 +444,7 @@ async def test_service_lock_keys_tab_a_temporary( patch.object( MockModuleConnection, "lock_keys_tab_a_temporary" ) as lock_keys_tab_a_temporary, - pytest.raises(ValueError), + pytest.raises(ServiceValidationError) as exc_info, ): await hass.services.async_call( DOMAIN, @@ -420,6 +458,8 @@ async def test_service_lock_keys_tab_a_temporary( }, blocking=True, ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_lock_keys_table" async def test_service_dyn_text( diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py index 02bf6b4c546..75d8a605bfb 100644 --- a/tests/components/lcn/test_websocket.py +++ b/tests/components/lcn/test_websocket.py @@ -192,6 +192,16 @@ async def test_lcn_entities_add_command( assert entity_config in entry.data[CONF_ENTITIES] + # invalid domain + await client.send_json_auto_id( + {**ENTITIES_ADD_PAYLOAD, "entry_id": entry.entry_id, CONF_DOMAIN: "invalid"} + ) + + res = await client.receive_json() + assert not res["success"] + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["translation_key"] == "invalid_domain" + async def test_lcn_entities_delete_command( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry diff --git a/tests/components/leaone/__init__.py b/tests/components/leaone/__init__.py index befc0a81028..900fe100940 100644 --- a/tests/components/leaone/__init__.py +++ b/tests/components/leaone/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index 35183bf5d75..e1b5a48fe27 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '500006', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Lektrico', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '500006', - 'suggested_area': None, 'sw_version': '1.44', 'via_device_id': None, }) diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index 6e73bb430cf..644b8e1580f 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -6,6 +6,7 @@ from letpot.models import ( AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus, + LightMode, TemperatureUnit, ) @@ -32,8 +33,8 @@ AUTHENTICATION = AuthenticationInfo( MAX_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), - light_brightness=500, - light_mode=1, + light_brightness=750, + light_mode=LightMode.VEGETABLE, light_schedule_end=datetime.time(18, 0), light_schedule_start=datetime.time(8, 0), online=True, @@ -53,7 +54,7 @@ MAX_STATUS = LetPotDeviceStatus( SE_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True), light_brightness=500, - light_mode=1, + light_mode=LightMode.VEGETABLE, light_schedule_end=datetime.time(18, 0), light_schedule_start=datetime.time(8, 0), online=True, diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 25974b2d78a..4abf917cb9f 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,12 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus +from letpot.models import ( + DeviceFeature, + LetPotDevice, + LetPotDeviceInfo, + LetPotDeviceStatus, +) import pytest from homeassistant.components.letpot.const import ( @@ -26,13 +31,38 @@ def device_type() -> str: return "LPH63" +def _mock_device_info(device_type: str) -> LetPotDeviceInfo: + """Return mock device info for the given type.""" + return LetPotDeviceInfo( + model=device_type, + model_name=f"LetPot {device_type}", + model_code=device_type, + features=_mock_device_features(device_type), + ) + + def _mock_device_features(device_type: str) -> DeviceFeature: """Return mock device feature support for the given type.""" if device_type == "LPH31": - return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS + return ( + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH + | DeviceFeature.PUMP_STATUS + ) + if device_type == "LPH62": + return ( + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.TEMPERATURE + | DeviceFeature.TEMPERATURE_SET_UNIT + | DeviceFeature.WATER_LEVEL + ) if device_type == "LPH63": return ( - DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LEVELS | DeviceFeature.NUTRIENT_BUTTON | DeviceFeature.PUMP_AUTO | DeviceFeature.PUMP_STATUS @@ -46,11 +76,20 @@ def _mock_device_status(device_type: str) -> LetPotDeviceStatus: """Return mock device status for the given type.""" if device_type == "LPH31": return SE_STATUS - if device_type == "LPH63": + if device_type in {"LPH62", "LPH63"}: return MAX_STATUS raise ValueError(f"No mock data for device type {device_type}") +def _mock_light_brightness_levels(device_type: str) -> list[int]: + """Return mock brightness levels for the given type.""" + if device_type == "LPH31": + return [500, 1000] + if device_type in {"LPH62", "LPH63"}: + return [125, 250, 375, 500, 625, 750, 875, 1000] + raise ValueError(f"No mock data for device type {device_type}") + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -89,32 +128,36 @@ def mock_client(device_type: str) -> Generator[AsyncMock]: @pytest.fixture -def mock_device_client(device_type: str) -> Generator[AsyncMock]: +def mock_device_client() -> Generator[AsyncMock]: """Mock a LetPotDeviceClient.""" with patch( - "homeassistant.components.letpot.coordinator.LetPotDeviceClient", + "homeassistant.components.letpot.LetPotDeviceClient", autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_features = _mock_device_features(device_type) - device_client.device_model_code = device_type - device_client.device_model_name = f"LetPot {device_type}" - device_status = _mock_device_status(device_type) - subscribe_callbacks: list[Callable] = [] + subscribe_callbacks: dict[str, Callable] = {} - def subscribe_side_effect(callback: Callable) -> None: - subscribe_callbacks.append(callback) + def subscribe_side_effect(serial: str, callback: Callable) -> None: + subscribe_callbacks[serial] = callback - def status_side_effect() -> None: - # Deliver a status update to any subscribers, like the real client - for callback in subscribe_callbacks: - callback(device_status) + def request_status_side_effect(serial: str) -> None: + # Deliver a status update to the subscriber, like the real client + if (callback := subscribe_callbacks.get(serial)) is not None: + callback(_mock_device_status(serial[:5])) - device_client.get_current_status.side_effect = status_side_effect - device_client.get_current_status.return_value = device_status - device_client.last_status.return_value = device_status - device_client.request_status_update.side_effect = status_side_effect + def get_current_status_side_effect(serial: str) -> LetPotDeviceStatus: + request_status_side_effect(serial) + return _mock_device_status(serial[:5]) + + device_client.device_info.side_effect = lambda serial: _mock_device_info( + serial[:5] + ) + device_client.get_light_brightness_levels.side_effect = ( + lambda serial: _mock_light_brightness_levels(serial[:5]) + ) + device_client.get_current_status.side_effect = get_current_status_side_effect + device_client.request_status_update.side_effect = request_status_side_effect device_client.subscribe.side_effect = subscribe_side_effect yield device_client diff --git a/tests/components/linear_garage_door/snapshots/test_light.ambr b/tests/components/letpot/snapshots/test_select.ambr similarity index 52% rename from tests/components/linear_garage_door/snapshots/test_light.ambr rename to tests/components/letpot/snapshots/test_select.ambr index 930d78d4706..5d9ddf0d0d3 100644 --- a/tests/components/linear_garage_door/snapshots/test_light.ambr +++ b/tests/components/letpot/snapshots/test_select.ambr @@ -1,12 +1,13 @@ # serializer version: 1 -# name: test_data[light.test_garage_1_light-entry] +# name: test_all_entities[LPH31][select.garden_light_brightness-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'supported_color_modes': list([ - , + 'options': list([ + 'low', + 'high', ]), }), 'config_entry_id': , @@ -14,9 +15,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.test_garage_1_light', + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_light_brightness', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -28,43 +29,42 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Light', - 'platform': 'linear_garage_door', + 'original_name': 'Light brightness', + 'platform': 'letpot', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test1-Light', + 'translation_key': 'light_brightness', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_light_brightness_low_high', 'unit_of_measurement': None, }) # --- -# name: test_data[light.test_garage_1_light-state] +# name: test_all_entities[LPH31][select.garden_light_brightness-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': 255, - 'color_mode': , - 'friendly_name': 'Test Garage 1 Light', - 'supported_color_modes': list([ - , + 'friendly_name': 'Garden Light brightness', + 'options': list([ + 'low', + 'high', ]), - 'supported_features': , }), 'context': , - 'entity_id': 'light.test_garage_1_light', + 'entity_id': 'select.garden_light_brightness', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'low', }) # --- -# name: test_data[light.test_garage_2_light-entry] +# name: test_all_entities[LPH31][select.garden_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'supported_color_modes': list([ - , + 'options': list([ + 'flower', + 'vegetable', ]), }), 'config_entry_id': , @@ -72,9 +72,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.test_garage_2_light', + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_light_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -86,43 +86,42 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Light', - 'platform': 'linear_garage_door', + 'original_name': 'Light mode', + 'platform': 'letpot', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test2-Light', + 'translation_key': 'light_mode', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_light_mode', 'unit_of_measurement': None, }) # --- -# name: test_data[light.test_garage_2_light-state] +# name: test_all_entities[LPH31][select.garden_light_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Test Garage 2 Light', - 'supported_color_modes': list([ - , + 'friendly_name': 'Garden Light mode', + 'options': list([ + 'flower', + 'vegetable', ]), - 'supported_features': , }), 'context': , - 'entity_id': 'light.test_garage_2_light', + 'entity_id': 'select.garden_light_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'vegetable', }) # --- -# name: test_data[light.test_garage_3_light-entry] +# name: test_all_entities[LPH62][select.garden_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'supported_color_modes': list([ - , + 'options': list([ + 'flower', + 'vegetable', ]), }), 'config_entry_id': , @@ -130,9 +129,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.test_garage_3_light', + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_light_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -144,43 +143,42 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Light', - 'platform': 'linear_garage_door', + 'original_name': 'Light mode', + 'platform': 'letpot', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test3-Light', + 'translation_key': 'light_mode', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH62ABCD_light_mode', 'unit_of_measurement': None, }) # --- -# name: test_data[light.test_garage_3_light-state] +# name: test_all_entities[LPH62][select.garden_light_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Test Garage 3 Light', - 'supported_color_modes': list([ - , + 'friendly_name': 'Garden Light mode', + 'options': list([ + 'flower', + 'vegetable', ]), - 'supported_features': , }), 'context': , - 'entity_id': 'light.test_garage_3_light', + 'entity_id': 'select.garden_light_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'vegetable', }) # --- -# name: test_data[light.test_garage_4_light-entry] +# name: test_all_entities[LPH62][select.garden_temperature_unit_on_display-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'supported_color_modes': list([ - , + 'options': list([ + 'fahrenheit', + 'celsius', ]), }), 'config_entry_id': , @@ -188,9 +186,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.test_garage_4_light', + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_temperature_unit_on_display', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -202,32 +200,30 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Light', - 'platform': 'linear_garage_door', + 'original_name': 'Temperature unit on display', + 'platform': 'letpot', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test4-Light', + 'translation_key': 'display_temperature_unit', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH62ABCD_display_temperature_unit', 'unit_of_measurement': None, }) # --- -# name: test_data[light.test_garage_4_light-state] +# name: test_all_entities[LPH62][select.garden_temperature_unit_on_display-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': 255, - 'color_mode': , - 'friendly_name': 'Test Garage 4 Light', - 'supported_color_modes': list([ - , + 'friendly_name': 'Garden Temperature unit on display', + 'options': list([ + 'fahrenheit', + 'celsius', ]), - 'supported_features': , }), 'context': , - 'entity_id': 'light.test_garage_4_light', + 'entity_id': 'select.garden_temperature_unit_on_display', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'celsius', }) # --- diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py index e3f78d87dc1..8357b4da67e 100644 --- a/tests/components/letpot/test_init.py +++ b/tests/components/letpot/test_init.py @@ -37,7 +37,7 @@ async def test_load_unload_config_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - mock_device_client.disconnect.assert_called_once() + mock_device_client.unsubscribe.assert_called_once() @pytest.mark.freeze_time("2025-02-15 00:00:00") diff --git a/tests/components/letpot/test_select.py b/tests/components/letpot/test_select.py new file mode 100644 index 00000000000..d576ca6fca6 --- /dev/null +++ b/tests/components/letpot/test_select.py @@ -0,0 +1,102 @@ +"""Test select entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from letpot.exceptions import LetPotConnectionException, LetPotException +from letpot.models import LightMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("device_type", ["LPH62", "LPH31"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_type: str, +) -> None: + """Test switch entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("device_type", ["LPH31"]) +async def test_set_select( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + device_type: str, +) -> None: + """Test select entity set to value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.garden_light_brightness", + ATTR_OPTION: "high", + }, + blocking=True, + ) + + mock_device_client.set_light_brightness.assert_awaited_once_with( + f"{device_type}ABCD", 1000 + ) + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_select_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test select entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_light_mode.side_effect = exception + + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.garden_light_mode", + ATTR_OPTION: LightMode.FLOWER.name.lower(), + }, + blocking=True, + ) diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index 7eeafd78291..b1b4b48b7bb 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -58,6 +58,7 @@ async def test_set_switch( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, service: str, parameter_value: bool, ) -> None: @@ -71,7 +72,9 @@ async def test_set_switch( target={"entity_id": "switch.garden_power"}, ) - mock_device_client.set_power.assert_awaited_once_with(parameter_value) + mock_device_client.set_power.assert_awaited_once_with( + f"{device_type}ABCD", parameter_value + ) @pytest.mark.parametrize( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index dba51ce8497..5c84b6a0159 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -38,6 +38,7 @@ async def test_set_time( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, ) -> None: """Test setting the time entity.""" await setup_integration(hass, mock_config_entry) @@ -50,7 +51,9 @@ async def test_set_time( target={"entity_id": "time.garden_light_on"}, ) - mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + mock_device_client.set_light_schedule.assert_awaited_once_with( + f"{device_type}ABCD", time(7, 0), None + ) @pytest.mark.parametrize( diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index fd1b31e80bf..754969ff549 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -18,6 +18,7 @@ 'max_temp': 86, 'min_temp': 64, 'preset_modes': list([ + 'none', 'air_clean', ]), 'swing_horizontal_modes': list([ @@ -78,8 +79,9 @@ ]), 'max_temp': 86, 'min_temp': 64, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ + 'none', 'air_clean', ]), 'supported_features': , diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index d561c4c6fc9..3f42d7e4f5c 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -135,7 +135,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm1', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[sensor.test_air_conditioner_pm1-state] @@ -144,7 +144,7 @@ 'device_class': 'pm1', 'friendly_name': 'Test air conditioner PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.test_air_conditioner_pm1', @@ -188,7 +188,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm10', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[sensor.test_air_conditioner_pm10-state] @@ -197,7 +197,7 @@ 'device_class': 'pm10', 'friendly_name': 'Test air conditioner PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.test_air_conditioner_pm10', @@ -241,7 +241,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[sensor.test_air_conditioner_pm2_5-state] @@ -250,7 +250,7 @@ 'device_class': 'pm25', 'friendly_name': 'Test air conditioner PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.test_air_conditioner_pm2_5', diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index 7f601cd02c3..a46162723f0 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT @@ -16,7 +17,7 @@ from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="LG_Smart_Dryer2_open", - macaddress="34:E6:E6:11:22:33", + macaddress=dr.format_mac("34:E6:E6:11:22:33").replace(":", ""), ) diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 81b913da6ce..95f6154030b 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -199,6 +199,17 @@ def _mocked_ceiling() -> Light: return bulb +def _mocked_128zone_ceiling() -> Light: + bulb = _mocked_bulb() + bulb.product = 201 # LIFX 26"x13" Ceiling + bulb.effect = {"effect": "OFF"} + bulb.get_tile_effect = MockLifxCommand(bulb) + bulb.set_tile_effect = MockLifxCommand(bulb) + bulb.get64 = MockLifxCommand(bulb) + bulb.get_device_chain = MockLifxCommand(bulb) + return bulb + + def _mocked_bulb_old_firmware() -> Light: bulb = _mocked_bulb() bulb.host_firmware_version = "2.77" diff --git a/tests/components/lifx/snapshots/test_diagnostics.ambr b/tests/components/lifx/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3e095252159 --- /dev/null +++ b/tests/components/lifx/snapshots/test_diagnostics.ambr @@ -0,0 +1,1568 @@ +# serializer version: 1 +# name: test_128zone_matrix_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': True, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'matrix': dict({ + 'chain': dict({ + '0': list([ + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + ]), + }), + 'chain_length': 1, + 'effect': dict({ + 'effect': 'OFF', + }), + 'tile_device_width': 16, + 'tile_devices': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 2000, + 'device_version_product': 201, + 'device_version_vendor': 1, + 'firmware_build': 1729829374000000000, + 'firmware_version_major': 4, + 'firmware_version_minor': 10, + 'height': 16, + 'supported_frame_buffers': 5, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, + }), + ]), + 'tile_devices_count': 1, + }), + 'power': 0, + 'product_id': 201, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 2500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 1, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_clean_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': True, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hev': dict({ + 'hev_config': dict({ + 'duration': 7200, + 'indication': False, + }), + 'hev_cycle': dict({ + 'duration': 7200, + 'last_power': False, + 'remaining': 30, + }), + 'last_result': 0, + }), + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 90, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_infrared_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': True, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'infrared': dict({ + 'brightness': 65535, + }), + 'kelvin': 4, + 'power': 0, + 'product_id': 29, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_legacy_multizone_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 2500, + 'multizone': True, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 31, + 'saturation': 2, + 'vendor': None, + 'zones': dict({ + 'count': 8, + 'state': dict({ + '0': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '1': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '2': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '3': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '4': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '5': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '6': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '7': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_matrix_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': True, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'matrix': dict({ + 'chain': dict({ + '0': list([ + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + ]), + }), + 'chain_length': 1, + 'effect': dict({ + 'effect': 'OFF', + }), + 'tile_device_width': 8, + 'tile_devices': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 2000, + 'device_version_product': 176, + 'device_version_vendor': 1, + 'firmware_build': 1729829374000000000, + 'firmware_version_major': 4, + 'firmware_version_minor': 10, + 'height': 8, + 'supported_frame_buffers': 5, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, + }), + ]), + 'tile_devices_count': 1, + }), + 'power': 0, + 'product_id': 176, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_multizone_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': True, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_ext_mz_firmware': 1532997580, + 'min_ext_mz_firmware_components': list([ + 2, + 77, + ]), + 'min_kelvin': 1500, + 'multizone': True, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 38, + 'saturation': 2, + 'vendor': None, + 'zones': dict({ + 'count': 8, + 'state': dict({ + '0': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '1': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '2': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '3': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '4': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '5': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '6': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '7': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index e2a35bcb1b1..1b09d742876 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -14,7 +14,11 @@ from homeassistant.components.lifx.const import CONF_SERIAL from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID, @@ -585,6 +589,7 @@ async def test_refuse_relays(hass: HomeAssistant) -> None: async def test_suggested_area( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: @@ -624,4 +629,4 @@ async def test_suggested_area( entity = entity_registry.async_get(entity_id) device = device_registry.async_get(entity.device_id) - assert device.suggested_area == "My LIFX Group" + assert device.area_id == area_registry.async_get_area_by_name("My LIFX Group").id diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 22e335612f8..830dc26829a 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -1,5 +1,7 @@ """Test LIFX diagnostics.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.components import lifx from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -10,7 +12,9 @@ from . import ( IP_ADDRESS, SERIAL, MockLifxCommand, + _mocked_128zone_ceiling, _mocked_bulb, + _mocked_ceiling, _mocked_clean_bulb, _mocked_infrared_bulb, _mocked_light_strip, @@ -25,7 +29,9 @@ from tests.typing import ClientSessionGenerator async def test_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -45,36 +51,13 @@ async def test_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 2500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 1, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_clean_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -94,41 +77,13 @@ async def test_clean_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": True, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 1500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hev": { - "hev_config": {"duration": 7200, "indication": False}, - "hev_cycle": {"duration": 7200, "last_power": False, "remaining": 30}, - "last_result": 0, - }, - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 90, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_infrared_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -148,37 +103,13 @@ async def test_infrared_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": True, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 1500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "infrared": {"brightness": 65535}, - "kelvin": 4, - "power": 0, - "product_id": 29, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_legacy_multizone_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -225,89 +156,13 @@ async def test_legacy_multizone_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 2500, - "multizone": True, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 31, - "saturation": 2, - "vendor": None, - "zones": { - "count": 8, - "state": { - "0": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "1": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "2": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "3": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "4": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "5": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "6": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "7": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - }, - }, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_multizone_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -355,84 +210,102 @@ async def test_multizone_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": True, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_ext_mz_firmware": 1532997580, - "min_ext_mz_firmware_components": [2, 77], - "min_kelvin": 1500, - "multizone": True, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 38, - "saturation": 2, - "vendor": None, - "zones": { - "count": 8, - "state": { - "0": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "1": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "2": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "3": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "4": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "5": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "6": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "7": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - }, - }, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot + + +async def test_matrix_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_ceiling() + bulb.effect = {"effect": "OFF"} + bulb.tile_devices_count = 1 + bulb.tile_device_width = 8 + bulb.tile_devices = [ + { + "accel_meas_x": 0, + "accel_meas_y": 0, + "accel_meas_z": 2000, + "user_x": 0.0, + "user_y": 0.0, + "width": 8, + "height": 8, + "supported_frame_buffers": 5, + "device_version_vendor": 1, + "device_version_product": 176, + "firmware_build": 1729829374000000000, + "firmware_version_minor": 10, + "firmware_version_major": 4, + } + ] + bulb.chain = {0: [(0, 0, 0, 3500)] * 64} + bulb.chain_length = 1 + + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot + + +async def test_128zone_matrix_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_128zone_ceiling() + bulb.effect = {"effect": "OFF"} + bulb.tile_devices_count = 1 + bulb.tile_device_width = 16 + bulb.tile_devices = [ + { + "accel_meas_x": 0, + "accel_meas_y": 0, + "accel_meas_z": 2000, + "user_x": 0.0, + "user_y": 0.0, + "width": 8, + "height": 16, + "supported_frame_buffers": 5, + "device_version_vendor": 1, + "device_version_product": 201, + "firmware_build": 1729829374000000000, + "firmware_version_minor": 10, + "firmware_version_major": 4, + } + ] + bulb.chain = {0: [(0, 0, 0, 3500)] * 128} + bulb.chain_length = 1 + + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index d66908c1b1a..edb13c259e8 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -30,6 +30,8 @@ from homeassistant.components.lifx.manager import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, @@ -1735,6 +1737,48 @@ async def test_transitions_color_bulb(hass: HomeAssistant) -> None: bulb.set_color.reset_mock() +async def test_lifx_set_state_brightness(hass: HomeAssistant) -> None: + """Test lifx.set_state works with brightness, brightness_pct and brightness_step.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb_new_firmware() + bulb.power_level = 65535 + bulb.color = [0, 0, 32768, 3500] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + # brightness_step should convert from 8 bit to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP: 128}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + # brightness_step_pct should convert from percentage to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP_PCT: 50}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + async def test_lifx_set_state_color(hass: HomeAssistant) -> None: """Test lifx.set_state works with color names and RGB.""" config_entry = MockConfigEntry( diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index c2ac7087cf0..1f69b586c9b 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -194,6 +194,7 @@ async def test_get_action_capabilities( { "name": "brightness_pct", "optional": True, + "required": False, "type": "float", "valueMax": 100, "valueMin": 0, @@ -219,6 +220,7 @@ async def test_get_action_capabilities( { "name": "brightness_pct", "optional": True, + "required": False, "type": "float", "valueMax": 100, "valueMin": 0, @@ -238,6 +240,7 @@ async def test_get_action_capabilities( { "name": "flash", "optional": True, + "required": False, "type": "select", "options": [("short", "short"), ("long", "long")], } @@ -256,6 +259,7 @@ async def test_get_action_capabilities( { "name": "flash", "optional": True, + "required": False, "type": "select", "options": [("short", "short"), ("long", "long")], } @@ -341,6 +345,7 @@ async def test_get_action_capabilities_features( { "name": "brightness_pct", "optional": True, + "required": False, "type": "float", "valueMax": 100, "valueMin": 0, @@ -366,6 +371,7 @@ async def test_get_action_capabilities_features( { "name": "brightness_pct", "optional": True, + "required": False, "type": "float", "valueMax": 100, "valueMin": 0, @@ -385,6 +391,7 @@ async def test_get_action_capabilities_features( { "name": "flash", "optional": True, + "required": False, "type": "select", "options": [("short", "short"), ("long", "long")], } @@ -403,6 +410,7 @@ async def test_get_action_capabilities_features( { "name": "flash", "optional": True, + "required": False, "type": "select", "options": [("short", "short"), ("long", "long")], } diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 2a5c9f0bb18..1dabe6e6071 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -128,7 +128,12 @@ async def test_get_condition_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( @@ -158,7 +163,12 @@ async def test_get_condition_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index ae54bbd2512..99e0a5e5b93 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -133,7 +133,12 @@ async def test_get_trigger_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( @@ -163,7 +168,12 @@ async def test_get_trigger_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( diff --git a/tests/components/linear_garage_door/__init__.py b/tests/components/linear_garage_door/__init__.py deleted file mode 100644 index 67bd1ee2da2..00000000000 --- a/tests/components/linear_garage_door/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Tests for the Linear Garage Door integration.""" - -from unittest.mock import patch - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] -) -> None: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.linear_garage_door.PLATFORMS", - platforms, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() diff --git a/tests/components/linear_garage_door/conftest.py b/tests/components/linear_garage_door/conftest.py deleted file mode 100644 index 4ed7662e5d0..00000000000 --- a/tests/components/linear_garage_door/conftest.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Common fixtures for the Linear Garage Door tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -import pytest - -from homeassistant.components.linear_garage_door import DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD - -from tests.common import ( - MockConfigEntry, - load_json_array_fixture, - load_json_object_fixture, -) - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.linear_garage_door.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_linear() -> Generator[AsyncMock]: - """Mock a Linear Garage Door client.""" - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear", - autospec=True, - ) as mock_client, - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear", - new=mock_client, - ), - ): - client = mock_client.return_value - client.login.return_value = True - client.get_devices.return_value = load_json_array_fixture( - "get_devices.json", DOMAIN - ) - client.get_sites.return_value = load_json_array_fixture( - "get_sites.json", DOMAIN - ) - device_states = load_json_object_fixture("get_device_state.json", DOMAIN) - client.get_device_state.side_effect = lambda device_id: device_states[device_id] - yield client - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Mock a config entry.""" - return MockConfigEntry( - domain=DOMAIN, - entry_id="acefdd4b3a4a0911067d1cf51414201e", - title="test-site-name", - data={ - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) diff --git a/tests/components/linear_garage_door/fixtures/get_device_state.json b/tests/components/linear_garage_door/fixtures/get_device_state.json deleted file mode 100644 index 14247610e06..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_device_state.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "test1": { - "GDO": { - "Open_B": "true", - "Open_P": "100" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - }, - "test2": { - "GDO": { - "Open_B": "false", - "Open_P": "0" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test3": { - "GDO": { - "Open_B": "false", - "Opening_P": "0" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test4": { - "GDO": { - "Open_B": "true", - "Opening_P": "100" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - } -} diff --git a/tests/components/linear_garage_door/fixtures/get_device_state_1.json b/tests/components/linear_garage_door/fixtures/get_device_state_1.json deleted file mode 100644 index 1f41d4fd153..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_device_state_1.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "test1": { - "GDO": { - "Open_B": "true", - "Opening_P": "100" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test2": { - "GDO": { - "Open_B": "false", - "Opening_P": "0" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - }, - "test3": { - "GDO": { - "Open_B": "false", - "Opening_P": "0" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test4": { - "GDO": { - "Open_B": "true", - "Opening_P": "100" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - } -} diff --git a/tests/components/linear_garage_door/fixtures/get_devices.json b/tests/components/linear_garage_door/fixtures/get_devices.json deleted file mode 100644 index da6eeaf7448..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_devices.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "id": "test1", - "name": "Test Garage 1", - "subdevices": ["GDO", "Light"] - }, - { - "id": "test2", - "name": "Test Garage 2", - "subdevices": ["GDO", "Light"] - }, - { - "id": "test3", - "name": "Test Garage 3", - "subdevices": ["GDO", "Light"] - }, - { - "id": "test4", - "name": "Test Garage 4", - "subdevices": ["GDO", "Light"] - } -] diff --git a/tests/components/linear_garage_door/fixtures/get_sites.json b/tests/components/linear_garage_door/fixtures/get_sites.json deleted file mode 100644 index 2b0a49b9007..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_sites.json +++ /dev/null @@ -1 +0,0 @@ -[{ "id": "test-site-id", "name": "test-site-name" }] diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr deleted file mode 100644 index db82f41eb73..00000000000 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ /dev/null @@ -1,83 +0,0 @@ -# serializer version: 1 -# name: test_entry_diagnostics - dict({ - 'coordinator_data': dict({ - 'test1': dict({ - 'name': 'Test Garage 1', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'true', - 'Open_P': '100', - }), - 'Light': dict({ - 'On_B': 'true', - 'On_P': '100', - }), - }), - }), - 'test2': dict({ - 'name': 'Test Garage 2', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'false', - 'Open_P': '0', - }), - 'Light': dict({ - 'On_B': 'false', - 'On_P': '0', - }), - }), - }), - 'test3': dict({ - 'name': 'Test Garage 3', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'false', - 'Opening_P': '0', - }), - 'Light': dict({ - 'On_B': 'false', - 'On_P': '0', - }), - }), - }), - 'test4': dict({ - 'name': 'Test Garage 4', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'true', - 'Opening_P': '100', - }), - 'Light': dict({ - 'On_B': 'true', - 'On_P': '100', - }), - }), - }), - }), - 'entry': dict({ - 'data': dict({ - 'device_id': 'test-uuid', - 'email': '**REDACTED**', - 'password': '**REDACTED**', - 'site_id': 'test-site-id', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'linear_garage_door', - 'entry_id': 'acefdd4b3a4a0911067d1cf51414201e', - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'subentries': list([ - ]), - 'title': 'test-site-name', - 'unique_id': None, - 'version': 1, - }), - }) -# --- diff --git a/tests/components/linear_garage_door/test_config_flow.py b/tests/components/linear_garage_door/test_config_flow.py deleted file mode 100644 index 64bdc589194..00000000000 --- a/tests/components/linear_garage_door/test_config_flow.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Test the Linear Garage Door config flow.""" - -from unittest.mock import AsyncMock, patch - -from linear_garage_door.errors import InvalidLoginError -import pytest - -from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form( - hass: HomeAssistant, mock_linear: AsyncMock, mock_setup_entry: AsyncMock -) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] - - with patch( - "uuid.uuid4", - return_value="test-uuid", - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"site": "test-site-id"} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-site-name" - assert result["data"] == { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_reauth( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test reauthentication.""" - mock_config_entry.add_to_hass(hass) - result = await mock_config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - with patch( - "uuid.uuid4", - return_value="test-uuid", - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "new-email", - CONF_PASSWORD: "new-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - assert mock_config_entry.data == { - CONF_EMAIL: "new-email", - CONF_PASSWORD: "new-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - } - - -@pytest.mark.parametrize( - ("side_effect", "expected_error"), - [(InvalidLoginError, "invalid_auth"), (Exception, "unknown")], -) -async def test_form_exceptions( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_setup_entry: AsyncMock, - side_effect: Exception, - expected_error: str, -) -> None: - """Test we handle invalid auth.""" - mock_linear.login.side_effect = side_effect - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": expected_error} - mock_linear.login.side_effect = None - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - }, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"site": "test-site-id"} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py deleted file mode 100644 index c031db88180..00000000000 --- a/tests/components/linear_garage_door/test_cover.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Test Linear Garage Door cover.""" - -from datetime import timedelta -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.cover import ( - DOMAIN as COVER_DOMAIN, - SERVICE_CLOSE_COVER, - SERVICE_OPEN_COVER, - CoverState, -) -from homeassistant.components.linear_garage_door import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_json_object_fixture, - snapshot_platform, -) - - -async def test_covers( - hass: HomeAssistant, - mock_linear: AsyncMock, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that data gets parsed and returned appropriately.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -async def test_open_cover( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that opening the cover works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_1"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 0 - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_2"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_close_cover( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that closing the cover works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_2"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 0 - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_1"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_update_cover_state( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Test that closing the cover works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - assert hass.states.get("cover.test_garage_1").state == CoverState.OPEN - assert hass.states.get("cover.test_garage_2").state == CoverState.CLOSED - - device_states = await async_load_json_object_fixture( - hass, "get_device_state_1.json", DOMAIN - ) - mock_linear.get_device_state.side_effect = lambda device_id: device_states[ - device_id - ] - - freezer.tick(timedelta(seconds=60)) - async_fire_time_changed(hass) - - assert hass.states.get("cover.test_garage_1").state == CoverState.CLOSING - assert hass.states.get("cover.test_garage_2").state == CoverState.OPENING diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py deleted file mode 100644 index 2693eda60bb..00000000000 --- a/tests/components/linear_garage_door/test_init.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Test Linear Garage Door init.""" - -from unittest.mock import AsyncMock - -from linear_garage_door import InvalidLoginError -import pytest - -from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.config_entries import ( - SOURCE_IGNORE, - ConfigEntryDisabler, - ConfigEntryState, -) -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from . import setup_integration - -from tests.common import MockConfigEntry - - -async def test_unload_entry( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test the unload entry.""" - - await setup_integration(hass, mock_config_entry, []) - assert mock_config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -@pytest.mark.parametrize( - ("side_effect", "entry_state"), - [ - ( - InvalidLoginError( - "Login provided is invalid, please check the email and password" - ), - ConfigEntryState.SETUP_ERROR, - ), - (InvalidLoginError("Invalid login"), ConfigEntryState.SETUP_RETRY), - ], -) -async def test_setup_failure( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, - side_effect: Exception, - entry_state: ConfigEntryState, -) -> None: - """Test reauth trigger setup.""" - - mock_linear.login.side_effect = side_effect - - await setup_integration(hass, mock_config_entry, []) - assert mock_config_entry.state == entry_state - - -async def test_repair_issue( - hass: HomeAssistant, - mock_linear: AsyncMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the Linear Garage Door configuration entry loading/unloading handles the repair.""" - config_entry_1 = MockConfigEntry( - domain=DOMAIN, - entry_id="acefdd4b3a4a0911067d1cf51414201e", - title="test-site-name", - data={ - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - await setup_integration(hass, config_entry_1, []) - assert config_entry_1.state is ConfigEntryState.LOADED - - # Add a second one - config_entry_2 = MockConfigEntry( - domain=DOMAIN, - entry_id="acefdd4b3a4a0911067d1cf51414201f", - title="test-site-name", - data={ - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - await setup_integration(hass, config_entry_2, []) - assert config_entry_2.state is ConfigEntryState.LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - - # Add an ignored entry - config_entry_3 = MockConfigEntry( - source=SOURCE_IGNORE, - domain=DOMAIN, - ) - config_entry_3.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_3.entry_id) - await hass.async_block_till_done() - - assert config_entry_3.state is ConfigEntryState.NOT_LOADED - - # Add a disabled entry - config_entry_4 = MockConfigEntry( - disabled_by=ConfigEntryDisabler.USER, - domain=DOMAIN, - ) - config_entry_4.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_4.entry_id) - await hass.async_block_till_done() - - assert config_entry_4.state is ConfigEntryState.NOT_LOADED - - # Remove the first one - await hass.config_entries.async_remove(config_entry_1.entry_id) - await hass.async_block_till_done() - assert config_entry_1.state is ConfigEntryState.NOT_LOADED - assert config_entry_2.state is ConfigEntryState.LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - # Remove the second one - await hass.config_entries.async_remove(config_entry_2.entry_id) - await hass.async_block_till_done() - assert config_entry_1.state is ConfigEntryState.NOT_LOADED - assert config_entry_2.state is ConfigEntryState.NOT_LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None - - # Check the ignored and disabled entries are removed - assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/linear_garage_door/test_light.py b/tests/components/linear_garage_door/test_light.py deleted file mode 100644 index 1985b27aacd..00000000000 --- a/tests/components/linear_garage_door/test_light.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Test Linear Garage Door light.""" - -from datetime import timedelta -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.light import ( - DOMAIN as LIGHT_DOMAIN, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) -from homeassistant.components.linear_garage_door import DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_BRIGHTNESS, - STATE_OFF, - STATE_ON, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_json_object_fixture, - snapshot_platform, -) - - -async def test_data( - hass: HomeAssistant, - mock_linear: AsyncMock, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that data gets parsed and returned appropriately.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -async def test_turn_on( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that turning on the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_garage_2_light"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_turn_on_with_brightness( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that turning on the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_garage_2_light", CONF_BRIGHTNESS: 50}, - blocking=True, - ) - - mock_linear.operate_device.assert_called_once_with( - "test2", "Light", "DimPercent:20" - ) - - -async def test_turn_off( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that turning off the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_garage_1_light"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_update_light_state( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Test that turning off the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - assert hass.states.get("light.test_garage_1_light").state == STATE_ON - assert hass.states.get("light.test_garage_2_light").state == STATE_OFF - - device_states = await async_load_json_object_fixture( - hass, "get_device_state_1.json", DOMAIN - ) - mock_linear.get_device_state.side_effect = lambda device_id: device_states[ - device_id - ] - - freezer.tick(timedelta(seconds=60)) - async_fire_time_changed(hass) - - assert hass.states.get("light.test_garage_1_light").state == STATE_OFF - assert hass.states.get("light.test_garage_2_light").state == STATE_ON diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index d96ce06ca59..19c0c3600ea 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -159,6 +159,15 @@ PET_DATA = { "gender": "FEMALE", "lastWeightReading": 9.1, "breeds": ["sphynx"], + "weightHistory": [ + {"weight": 6.48, "timestamp": "2025-06-13T16:12:36"}, + {"weight": 6.6, "timestamp": "2025-06-14T03:52:00"}, + {"weight": 6.59, "timestamp": "2025-06-14T17:20:32"}, + {"weight": 6.5, "timestamp": "2025-06-14T19:22:48"}, + {"weight": 6.35, "timestamp": "2025-06-15T03:12:15"}, + {"weight": 6.45, "timestamp": "2025-06-15T15:27:21"}, + {"weight": 6.25, "timestamp": "2025-06-15T15:29:26"}, + ], } VACUUM_ENTITY_ID = "vacuum.test_litter_box" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index a6058c75bca..aa67db23d89 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -52,6 +52,20 @@ def create_mock_robot( return robot +def create_mock_pet( + pet_data: dict | None, + account: Account, + side_effect: Any | None = None, +) -> Pet: + """Create a mock Pet.""" + if not pet_data: + pet_data = {} + + pet = Pet(data={**PET_DATA, **pet_data}, session=account.session) + pet.fetch_weight_history = AsyncMock(side_effect=side_effect) + return pet + + def create_mock_account( robot_data: dict | None = None, side_effect: Any | None = None, @@ -69,7 +83,7 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) - account.pets = [Pet(PET_DATA, account.session)] if pet else [] + account.pets = [create_mock_pet(PET_DATA, account, side_effect)] if pet else [] return account diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index bbc6274e56b..d1101a4231d 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -5,7 +5,11 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.litterrobot.sensor import icon_for_gauge_level -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN, SensorDeviceClass +from homeassistant.components.sensor import ( + DOMAIN as PLATFORM_DOMAIN, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import PERCENTAGE, STATE_UNKNOWN, UnitOfMass from homeassistant.core import HomeAssistant @@ -70,6 +74,7 @@ async def test_gauge_icon() -> None: @pytest.mark.freeze_time("2022-09-18 23:00:44+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_litter_robot_sensor( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock ) -> None: @@ -94,6 +99,9 @@ async def test_litter_robot_sensor( sensor = hass.states.get("sensor.test_pet_weight") assert sensor.state == "12.0" assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS + sensor = hass.states.get("sensor.test_total_cycles") + assert sensor.state == "158" + assert sensor.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING async def test_feeder_robot_sensor( @@ -116,6 +124,16 @@ async def test_pet_weight_sensor( assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS +@pytest.mark.freeze_time("2025-06-15 12:00:00+00:00") +async def test_pet_visits_today_sensor( + hass: HomeAssistant, mock_account_with_pet: MagicMock +) -> None: + """Tests pet visits today sensors.""" + await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.kitty_visits_today") + assert sensor.state == "2" + + async def test_litterhopper_sensor( hass: HomeAssistant, mock_account_with_litterhopper: MagicMock ) -> None: diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 7d1c39d10f0..a5b8e47ef2f 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -155,7 +155,12 @@ async def test_get_trigger_capabilities( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } @@ -187,7 +192,12 @@ async def test_get_trigger_capabilities_legacy( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index edfc1e880f9..b74d9ef16e7 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -8,8 +8,7 @@ from unittest.mock import AsyncMock, Mock, patch from loqedAPI import loqed import pytest -from homeassistant.components.loqed import DOMAIN -from homeassistant.components.loqed.const import CONF_CLOUDHOOK_URL +from homeassistant.components.loqed.const import CONF_CLOUDHOOK_URL, DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/loqed/test_lock.py b/tests/components/loqed/test_lock.py index 89a7888571a..54e7f30bf51 100644 --- a/tests/components/loqed/test_lock.py +++ b/tests/components/loqed/test_lock.py @@ -3,8 +3,6 @@ from loqedAPI import loqed from homeassistant.components.lock import LockState -from homeassistant.components.loqed import LoqedDataCoordinator -from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, @@ -33,7 +31,7 @@ async def test_lock_responds_to_bolt_state_updates( hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock ) -> None: """Tests the lock responding to updates.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] + coordinator = integration.runtime_data lock.bolt_state = "night_lock" coordinator.async_update_listeners() diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py index ea9b6211823..46514529cbb 100644 --- a/tests/components/luftdaten/test_config_flow.py +++ b/tests/components/luftdaten/test_config_flow.py @@ -5,8 +5,7 @@ from unittest.mock import MagicMock from luftdaten.exceptions import LuftdatenConnectionError import pytest -from homeassistant.components.luftdaten import DOMAIN -from homeassistant.components.luftdaten.const import CONF_SENSOR_ID +from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py index f2cf12b3fda..bbabc486355 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -72,16 +72,16 @@ async def test_luftdaten_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.PA assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("sensor.sensor_12345_pressure_at_sealevel") + entry = entity_registry.async_get("sensor.sensor_12345_pressure_at_sea_level") assert entry assert entry.device_id assert entry.unique_id == "12345_pressure_at_sealevel" - state = hass.states.get("sensor.sensor_12345_pressure_at_sealevel") + state = hass.states.get("sensor.sensor_12345_pressure_at_sea_level") assert state assert state.state == "103102.13" assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 Pressure at sealevel" + state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 Pressure at sea level" ) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index 9198410f066..ec9da1836bc 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -45,6 +45,7 @@ 'limited': None, 'locked': False, 'memorial': None, + 'moved': None, 'moved_to_account': None, 'mute_expires_at': None, 'noindex': False, diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 46fb4c1d4e0..662ffd51cb4 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'trwnh_mastodon_social', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Mastodon gGmbH', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, }) diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py deleted file mode 100644 index 4242f88d34a..00000000000 --- a/tests/components/mastodon/test_notify.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for the Mastodon notify platform.""" - -from unittest.mock import AsyncMock - -from mastodon.Mastodon import MastodonAPIError -import pytest -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import MockConfigEntry - - -async def test_notify( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - mock_mastodon_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test sending a message.""" - await setup_integration(hass, mock_config_entry) - - assert hass.services.has_service(NOTIFY_DOMAIN, "trwnh_mastodon_social") - - await hass.services.async_call( - NOTIFY_DOMAIN, - "trwnh_mastodon_social", - { - "message": "test toot", - }, - blocking=True, - return_response=False, - ) - - assert mock_mastodon_client.status_post.assert_called_once - - -async def test_notify_failed( - hass: HomeAssistant, - mock_mastodon_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the notify raising an error.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_mastodon_client.status_post.side_effect = MastodonAPIError - - with pytest.raises(HomeAssistantError, match="Unable to send message"): - await hass.services.async_call( - NOTIFY_DOMAIN, - "trwnh_mastodon_social", - { - "message": "test toot", - }, - blocking=True, - return_response=False, - ) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index f51d39f8687..b08f886422f 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -6,7 +6,6 @@ from mastodon.Mastodon import MastodonAPIError, MediaAttachment import pytest from homeassistant.components.mastodon.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, @@ -15,6 +14,7 @@ from homeassistant.components.mastodon.const import ( DOMAIN, ) from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/matter/fixtures/nodes/microwave_oven.json b/tests/components/matter/fixtures/nodes/microwave_oven.json index bbba8b12e25..0e693b8337f 100644 --- a/tests/components/matter/fixtures/nodes/microwave_oven.json +++ b/tests/components/matter/fixtures/nodes/microwave_oven.json @@ -397,6 +397,8 @@ "1/96/5": { "0": 0 }, + "1/96/6": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + "1/96/7": 5, "1/96/65532": 2, "1/96/65533": 2, "1/96/65528": [4], diff --git a/tests/components/matter/fixtures/nodes/pump.json b/tests/components/matter/fixtures/nodes/pump.json index e4afc0b4f33..6d74b3d1b89 100644 --- a/tests/components/matter/fixtures/nodes/pump.json +++ b/tests/components/matter/fixtures/nodes/pump.json @@ -203,7 +203,7 @@ "1/6/65528": [], "1/6/65529": [0, 1, 2], "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], - "1/8/0": 254, + "1/8/0": 200, "1/8/15": 0, "1/8/17": 0, "1/8/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json index d0efcc7e004..fa66f4dfeef 100644 --- a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json @@ -588,31 +588,9 @@ "10": 101 } ], - "2/144/4": 120000, - "2/144/5": 0, - "2/144/6": 0, - "2/144/7": 0, "2/144/8": 0, - "2/144/9": 0, - "2/144/10": 0, "2/144/11": 120000, "2/144/12": 0, - "2/144/13": 0, - "2/144/14": 60, - "2/144/15": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/16": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/17": 9800, - "2/144/18": 0, "2/144/65532": 31, "2/144/65533": 1, "2/144/65528": [], diff --git a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json index 3188ba81ad6..3540f376f42 100644 --- a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json +++ b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json @@ -447,6 +447,7 @@ "1/153/37": null, "1/153/38": null, "1/153/39": null, + "1/153/48": 75, "1/153/64": 2, "1/153/65": 0, "1/153/66": 0, diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index 3b1ed0043de..93ba7e2e026 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -832,31 +832,9 @@ "10": 129 } ], - "2/144/4": 120000, - "2/144/5": 0, - "2/144/6": 0, - "2/144/7": 0, "2/144/8": 0, - "2/144/9": 0, - "2/144/10": 0, "2/144/11": 120000, "2/144/12": 0, - "2/144/13": 0, - "2/144/14": 60, - "2/144/15": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/16": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/17": 9800, - "2/144/18": 0, "2/144/65532": 31, "2/144/65533": 1, "2/144/65528": [], diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index f6c7780c517..7e2f1e7618e 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -685,7 +685,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry] +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charger_supply_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -698,7 +698,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'entity_id': 'binary_sensor.evse_charger_supply_state', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -710,24 +710,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Supply charging state', + 'original_name': 'Charger supply state', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'evse_supply_charging_state', + 'translation_key': 'evse_supply_state', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state] +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charger_supply_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', - 'friendly_name': 'evse Supply charging state', + 'friendly_name': 'evse Charger supply state', }), 'context': , - 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'entity_id': 'binary_sensor.evse_charger_supply_state', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 2ffbd248290..f70c38f6b6d 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2038,6 +2038,54 @@ 'state': 'unknown', }) # --- +# name: test_buttons[smoke_detector][button.smoke_sensor_self_test-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smoke_sensor_self_test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-test', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'self_test_request', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmSelfTestRequest-92-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[smoke_detector][button.smoke_sensor_self_test-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke sensor Self-test', + }), + 'context': , + 'entity_id': 'button.smoke_sensor_self_test', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[switch_unit][button.mock_switchunit_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index c8e2c03739a..c0b38a58456 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -124,7 +124,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -140,7 +140,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 51, - 'device_class': 'awning', + 'device_class': 'shade', 'friendly_name': 'Longan link WNCV DA01', 'supported_features': , }), @@ -175,7 +175,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -191,7 +191,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_tilt_position': 100, - 'device_class': 'awning', + 'device_class': 'blind', 'friendly_name': 'Mock PA Tilt Window Covering', 'supported_features': , }), @@ -226,7 +226,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -241,7 +241,7 @@ # name: test_covers[window_covering_tilt][cover.mock_tilt_window_covering-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'awning', + 'device_class': 'blind', 'friendly_name': 'Mock Tilt Window Covering', 'supported_features': , }), diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 5ba0f275f8d..24a92799082 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -402,7 +402,7 @@ 'state': '1.0', }) # --- -# name: test_numbers[door_lock][number.mock_door_lock_automatic_relock_timer-entry] +# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -420,7 +420,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -432,7 +432,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Automatic relock timer', + 'original_name': 'Autorelock time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -442,10 +442,10 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[door_lock][number.mock_door_lock_automatic_relock_timer-state] +# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Automatic relock timer', + 'friendly_name': 'Mock Door Lock Autorelock time', 'max': 65534, 'min': 0, 'mode': , @@ -453,14 +453,14 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '60', }) # --- -# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_automatic_relock_timer-entry] +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_autorelock_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -478,7 +478,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -490,7 +490,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Automatic relock timer', + 'original_name': 'Autorelock time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -500,10 +500,10 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_automatic_relock_timer-state] +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_autorelock_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Automatic relock timer', + 'friendly_name': 'Mock Door Lock Autorelock time', 'max': 65534, 'min': 0, 'mode': , @@ -511,7 +511,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -693,6 +693,65 @@ 'state': '255', }) # --- +# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 86400, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.microwave_oven_cook_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cook time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-MicrowaveOvenControlCookTime-95-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Microwave Oven Cook time', + 'max': 86400, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.microwave_oven_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- # name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -750,6 +809,120 @@ 'state': 'unavailable', }) # --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_off_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.inovelli_led_off_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED off intensity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_indicator_intensity_off', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOff-305134641-305070178', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_off_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli LED off intensity', + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.inovelli_led_off_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_on_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.inovelli_led_on_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED on intensity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_indicator_intensity_on', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOn-305134641-305070177', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_on_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli LED on intensity', + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.inovelli_led_on_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33', + }) +# --- # name: test_numbers[multi_endpoint_light][number.inovelli_off_transition_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1846,6 +2019,64 @@ 'state': '0.0', }) # --- +# name: test_numbers[oven][number.mock_oven_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 288.0, + 'min': 76.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_oven_temperature_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[oven][number.mock_oven_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Oven Temperature setpoint', + 'max': 288.0, + 'min': 76.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_oven_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '76.0', + }) +# --- # name: test_numbers[pump][number.mock_pump_on_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1903,3 +2134,235 @@ 'state': '0', }) # --- +# name: test_numbers[pump][number.mock_pump_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.5, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_pump_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_setpoint', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-pump_setpoint-8-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[pump][number.mock_pump_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump Setpoint', + 'max': 100, + 'min': 0.5, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_pump_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 0.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.laundrywasher_temperature_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Temperature setpoint', + 'max': 0.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.laundrywasher_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15.0, + 'min': -18.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.refrigerator_temperature_setpoint_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature setpoint (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-2-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Temperature setpoint (2)', + 'max': -15.0, + 'min': -18.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_temperature_setpoint_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 4.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.refrigerator_temperature_setpoint_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature setpoint (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-3-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Temperature setpoint (3)', + 'max': 4.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_temperature_setpoint_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 092928ff1d4..add827abc5a 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -981,6 +981,79 @@ 'state': 'Low', }) # --- +# name: test_selects[microwave_oven][select.microwave_oven_power_level_w-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '1000', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.microwave_oven_power_level_w', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power level (W)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_level', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-MicrowaveOvenControlSelectedWattIndex-95-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[microwave_oven][select.microwave_oven_power_level_w-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Power level (W)', + 'options': list([ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '1000', + ]), + }), + 'context': , + 'entity_id': 'select.microwave_oven_power_level_w', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- # name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 17841121445..290016f0ff3 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -250,7 +250,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -263,7 +263,7 @@ # name: test_sensors[air_purifier][sensor.air_purifier_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier Hepa filter condition', + 'friendly_name': 'Air Purifier HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), @@ -468,7 +468,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM1Sensor-1068-0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[air_purifier][sensor.air_purifier_pm1-state] @@ -477,7 +477,7 @@ 'device_class': 'pm1', 'friendly_name': 'Air Purifier PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.air_purifier_pm1', @@ -521,7 +521,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM10Sensor-1069-0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[air_purifier][sensor.air_purifier_pm10-state] @@ -530,7 +530,7 @@ 'device_class': 'pm10', 'friendly_name': 'Air Purifier PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.air_purifier_pm10', @@ -574,7 +574,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM25Sensor-1066-0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[air_purifier][sensor.air_purifier_pm2_5-state] @@ -583,7 +583,7 @@ 'device_class': 'pm25', 'friendly_name': 'Air Purifier PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.air_purifier_pm2_5', @@ -1017,7 +1017,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PM1Sensor-1068-0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_pm1-state] @@ -1026,7 +1026,7 @@ 'device_class': 'pm1', 'friendly_name': 'lightfi-aq1-air-quality-sensor PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_pm1', @@ -1070,7 +1070,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PM10Sensor-1069-0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_pm10-state] @@ -1079,7 +1079,7 @@ 'device_class': 'pm10', 'friendly_name': 'lightfi-aq1-air-quality-sensor PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_pm10', @@ -1123,7 +1123,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PM25Sensor-1066-0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_pm2_5-state] @@ -1132,7 +1132,7 @@ 'device_class': 'pm25', 'friendly_name': 'lightfi-aq1-air-quality-sensor PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_pm2_5', @@ -1251,6 +1251,65 @@ 'state': '189.0', }) # --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_active_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_active_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_current', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_active_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Mock Battery Storage Active current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_active_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1427,65 +1486,6 @@ 'state': '48.0', }) # --- -# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_battery_storage_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Mock Battery Storage Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.mock_battery_storage_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1763,7 +1763,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -2176,7 +2176,7 @@ 'state': '238.800003051758', }) # --- -# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_current-entry] +# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2191,7 +2191,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'entity_id': 'sensor.eve_energy_plug_patched_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2209,26 +2209,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', 'unit_of_measurement': , }) # --- -# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_current-state] +# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Eve Energy Plug Patched Current', + 'friendly_name': 'Eve Energy Plug Patched Active current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'entity_id': 'sensor.eve_energy_plug_patched_active_current', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2391,7 +2391,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -2906,6 +2906,68 @@ 'state': '16.03', }) # --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_weather_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'sunny', + 'cloudy', + 'rainy', + 'stormy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_weather_weather_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weather trend', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'eve_weather_trend', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-EveWeatherWeatherTrend-319486977-319422485', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_weather_trend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Eve Weather Weather trend', + 'options': list([ + 'sunny', + 'cloudy', + 'rainy', + 'stormy', + ]), + }), + 'context': , + 'entity_id': 'sensor.eve_weather_weather_trend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rainy', + }) +# --- # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_activated_carbon_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2985,7 +3047,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2998,7 +3060,7 @@ # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Extractor hood Hepa filter condition', + 'friendly_name': 'Mock Extractor hood HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), @@ -3443,6 +3505,55 @@ 'state': '1.3', }) # --- +# name: test_sensors[microwave_oven][sensor.microwave_oven_estimated_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_estimated_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated end time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_end_time', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalStateCountdownTime-96-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[microwave_oven][sensor.microwave_oven_estimated_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Microwave Oven Estimated end time', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_estimated_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T14:00:30+00:00', + }) +# --- # name: test_sensors[microwave_oven][sensor.microwave_oven_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3775,7 +3886,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'running', }) # --- # name: test_sensors[oven][sensor.mock_oven_temperature_2-entry] @@ -4284,7 +4395,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_current-entry] +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4299,7 +4410,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.dishwasher_current', + 'entity_id': 'sensor.dishwasher_effective_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4317,32 +4428,91 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Effective current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'translation_key': 'rms_current', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementRMSCurrent-144-12', 'unit_of_measurement': , }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_current-state] +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Dishwasher Current', + 'friendly_name': 'Dishwasher Effective current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_current', + 'entity_id': 'sensor.dishwasher_effective_current', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dishwasher_effective_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Effective voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_voltage', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementRMSVoltage-144-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Dishwasher Effective voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_effective_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[silabs_dishwasher][sensor.dishwasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4525,65 +4695,6 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.dishwasher_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Dishwasher Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dishwasher_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '120.0', - }) -# --- # name: test_sensors[silabs_evse_charging][sensor.evse_appliance_energy_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4973,6 +5084,59 @@ 'state': '2.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'evse_soc', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseStateOfCharge-153-48', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'evse State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.evse_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- # name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5032,65 +5196,6 @@ 'state': '32.0', }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.laundrywasher_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'LaundryWasher Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.laundrywasher_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5151,6 +5256,124 @@ 'state': 'pre-soak', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_effective_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Effective current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_current', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementRMSCurrent-144-12', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LaundryWasher Effective current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_effective_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_effective_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Effective voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_voltage', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementRMSVoltage-144-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LaundryWasher Effective voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_effective_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5331,7 +5554,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-entry] +# name: test_sensors[silabs_water_heater][sensor.water_heater_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5346,7 +5569,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.laundrywasher_voltage', + 'entity_id': 'sensor.water_heater_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5356,38 +5579,38 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', - 'unit_of_measurement': , + 'translation_key': 'active_current', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-state] +# name: test_sensors[silabs_water_heater][sensor.water_heater_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'LaundryWasher Voltage', + 'device_class': 'current', + 'friendly_name': 'Water Heater Active current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.laundrywasher_voltage', + 'entity_id': 'sensor.water_heater_active_current', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '120.0', + 'state': '0.1', }) # --- # name: test_sensors[silabs_water_heater][sensor.water_heater_appliance_energy_state-entry] @@ -5454,65 +5677,6 @@ 'state': 'online', }) # --- -# name: test_sensors[silabs_water_heater][sensor.water_heater_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.water_heater_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_water_heater][sensor.water_heater_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Water Heater Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.water_heater_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }) -# --- # name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5839,7 +6003,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -6020,7 +6184,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[solar_power][sensor.solarpower_current-entry] +# name: test_sensors[solar_power][sensor.solarpower_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6035,7 +6199,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.solarpower_current', + 'entity_id': 'sensor.solarpower_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6053,26 +6217,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', 'unit_of_measurement': , }) # --- -# name: test_sensors[solar_power][sensor.solarpower_current-state] +# name: test_sensors[solar_power][sensor.solarpower_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'SolarPower Current', + 'friendly_name': 'SolarPower Active current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.solarpower_current', + 'entity_id': 'sensor.solarpower_active_current', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6235,7 +6399,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -6433,7 +6597,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'stopped', }) # --- # name: test_sensors[window_covering_full][sensor.mock_full_window_covering_target_opening_position-entry] diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index cb859147d75..71e0f75614d 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -28,7 +28,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Vacuum', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.mock_vacuum', diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 873d6f17528..fcfd4da84c8 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -184,8 +184,8 @@ async def test_evse_sensor( assert state assert state.state == "off" - # Test SupplyStateEnum value with binary_sensor.evse_supply_charging - entity_id = "binary_sensor.evse_supply_charging_state" + # Test SupplyStateEnum value with binary_sensor.evse_charger_supply_state + entity_id = "binary_sensor.evse_charger_supply_state" state = hass.states.get(entity_id) assert state assert state.state == "on" diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index 2af2d40cb74..6452dabc10d 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -80,3 +80,30 @@ async def test_operational_state_buttons( endpoint_id=1, command=clusters.OperationalState.Commands.Pause(), ) + + +@pytest.mark.parametrize("node_fixture", ["smoke_detector"]) +async def test_smoke_detector_self_test( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test button entity is created for a Matter SmokeCoAlarm Cluster.""" + state = hass.states.get("button.smoke_sensor_self_test") + assert state + assert state.attributes["friendly_name"] == "Smoke sensor Self-test" + # test press action + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.smoke_sensor_self_test", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.SmokeCoAlarm.Commands.SelfTestRequest(), + ) diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index b600ededa6e..f9abf986170 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -131,6 +131,15 @@ async def test_dimmable_light( ) -> None: """Test a dimmable light.""" + # Test for currentLevel is None + set_node_attribute(matter_node, 1, 8, 0, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] is None + # Test that the light brightness is 50 (out of 254) set_node_attribute(matter_node, 1, 8, 0, 50) await trigger_subscription_callback(hass, matter_client) diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index c94b92dbc46..d35a889a436 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, call +from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode from matter_server.common import custom_clusters from matter_server.common.errors import MatterError @@ -101,6 +102,44 @@ async def test_eve_weather_sensor_altitude( ) +@pytest.mark.parametrize("node_fixture", ["silabs_refrigerator"]) +async def test_temperature_control_temperature_setpoint( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test TemperatureSetpoint from TemperatureControl.""" + # TemperatureSetpoint + state = hass.states.get("number.refrigerator_temperature_setpoint_2") + assert state + assert state.state == "-18.0" + + set_node_attribute(matter_node, 2, 86, 0, -1600) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.refrigerator_temperature_setpoint_2") + assert state + assert state.state == "-16.0" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.refrigerator_temperature_setpoint_2", + "value": -17, + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=2, + command=clusters.TemperatureControl.Commands.SetTemperature( + targetTemperature=-1700 + ), + ) + + @pytest.mark.parametrize("node_fixture", ["dimmable_light"]) async def test_matter_exception_on_write_attribute( hass: HomeAssistant, @@ -121,3 +160,77 @@ async def test_matter_exception_on_write_attribute( }, blocking=True, ) + + +@pytest.mark.parametrize("node_fixture", ["pump"]) +async def test_pump_level( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test level control for pump.""" + # CurrentLevel on LevelControl cluster + state = hass.states.get("number.mock_pump_setpoint") + assert state + assert state.state == "100.0" + + set_node_attribute(matter_node, 1, 8, 0, 100) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.mock_pump_setpoint") + assert state + assert state.state == "50.0" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_pump_setpoint", + "value": 75, + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert ( + matter_client.send_device_command.call_args + == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.LevelControl.Commands.MoveToLevel( + level=150 + ), # 75 * 2 = 150, as the value is multiplied by 2 in the HA to native value conversion + ) + ) + + +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_microwave_oven( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Cooktime for microwave oven.""" + + # Cooktime on MicrowaveOvenControl cluster (1/96/2) + state = hass.states.get("number.microwave_oven_cook_time") + assert state + assert state.state == "30" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.microwave_oven_cook_time", + "value": 60, # 60 seconds + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + cookTime=60, # 60 seconds + ), + ) diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 7045b60a24e..c264f51b669 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -235,3 +235,50 @@ async def test_pump( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.mock_pump_mode") assert state.state == "local" + + +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_microwave_oven( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test ListSelect entity is discovered and working from a microwave oven fixture.""" + + # SupportedWatts from MicrowaveOvenControl cluster (1/96/6) + # SelectedWattIndex from MicrowaveOvenControl cluster (1/96/7) + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.microwave_oven_power_level_w") + assert state + assert state.state == "1000" + assert state.attributes["options"] == [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + ] + + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.microwave_oven_power_level_w", + "option": "900", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + wattSettingIndex=8 + ), + ) diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 3e9af4a6e4b..883a976284e 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -17,6 +17,7 @@ from .common import ( ) +@pytest.mark.freeze_time("2025-01-01T14:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default", "matter_devices") async def test_sensors( hass: HomeAssistant, @@ -381,6 +382,21 @@ async def test_draft_electrical_measurement_sensor( assert state.state == "unknown" +@pytest.mark.freeze_time("2025-01-01T14:00:00+00:00") +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_countdown_time_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test CountdownTime sensor.""" + # OperationalState Cluster / CountdownTime (1/96/2) + state = hass.states.get("sensor.microwave_oven_estimated_end_time") + assert state + # 1/96/2 = 30 seconds, so 30 s should be added to the current time. + assert state.state == "2025-01-01T14:00:30+00:00" + + @pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) async def test_list_sensor( hass: HomeAssistant, diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 5bd90ee1109..cba4b9b59eb 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -9,7 +9,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotSupported +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -61,7 +61,29 @@ async def test_vacuum_actions( ) matter_client.send_device_command.reset_mock() - # test start/resume action + # test start action (from idle state) + await hass.services.async_call( + "vacuum", + "start", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1), + ) + matter_client.send_device_command.reset_mock() + + # test resume action (from paused state) + # first set the operational state to paused + set_node_attribute(matter_node, 1, 97, 4, 0x02) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( "vacuum", "start", @@ -93,30 +115,11 @@ async def test_vacuum_actions( assert matter_client.send_device_command.call_args == call( node_id=matter_node.node_id, endpoint_id=1, - command=clusters.OperationalState.Commands.Pause(), + command=clusters.RvcOperationalState.Commands.Pause(), ) matter_client.send_device_command.reset_mock() # test stop action - # stop command is not supported by the vacuum fixture - with pytest.raises( - ServiceNotSupported, - match="Entity vacuum.mock_vacuum does not support action vacuum.stop", - ): - await hass.services.async_call( - "vacuum", - "stop", - { - "entity_id": entity_id, - }, - blocking=True, - ) - - # update accepted command list to add support for stop command - set_node_attribute( - matter_node, 1, 97, 65529, [clusters.OperationalState.Commands.Stop.command_id] - ) - await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( "vacuum", "stop", @@ -129,7 +132,7 @@ async def test_vacuum_actions( assert matter_client.send_device_command.call_args == call( node_id=matter_node.node_id, endpoint_id=1, - command=clusters.OperationalState.Commands.Stop(), + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=0), ) matter_client.send_device_command.reset_mock() @@ -168,19 +171,26 @@ async def test_vacuum_updates( assert state assert state.state == "returning" - # confirm state is 'error' by setting the operational state to 0x01 + # confirm state is 'idle' by setting the operational state to 0x01 (running) but mode is idle set_node_attribute(matter_node, 1, 97, 4, 0x01) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.state == "error" + assert state.state == "idle" - # confirm state is 'error' by setting the operational state to 0x02 + # confirm state is 'idle' by setting the operational state to 0x01 (running) but mode is cleaning + set_node_attribute(matter_node, 1, 97, 4, 0x01) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "idle" + + # confirm state is 'paused' by setting the operational state to 0x02 set_node_attribute(matter_node, 1, 97, 4, 0x02) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.state == "error" + assert state.state == "paused" # confirm state is 'cleaning' by setting; # - the operational state to 0x00 @@ -202,12 +212,82 @@ async def test_vacuum_updates( assert state assert state.state == "idle" - # confirm state is 'unknown' by setting; + # confirm state is 'cleaning' by setting; # - the operational state to 0x00 - # - the run mode is set to a mode which has neither cleaning or idle tag + # - the run mode is set to a mode which has mapping tag set_node_attribute(matter_node, 1, 97, 4, 0) set_node_attribute(matter_node, 1, 84, 1, 2) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state + assert state.state == "cleaning" + + # confirm state is 'unknown' by setting; + # - the operational state to 0x00 + # - the run mode is set to a mode which has neither cleaning or idle tag + set_node_attribute(matter_node, 1, 97, 4, 0) + set_node_attribute(matter_node, 1, 84, 1, 5) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state assert state.state == "unknown" + + # confirm state is 'error' by setting; + # - the operational state to 0x03 + set_node_attribute(matter_node, 1, 97, 4, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "error" + + +@pytest.mark.parametrize("node_fixture", ["vacuum_cleaner"]) +async def test_vacuum_actions_no_supported_run_modes( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test vacuum entity actions when no supported run modes are available.""" + # Fetch translations + await async_setup_component(hass, "homeassistant", {}) + entity_id = "vacuum.mock_vacuum" + state = hass.states.get(entity_id) + assert state + + # Set empty supported modes to simulate no available run modes + # RvcRunMode cluster ID is 84, SupportedModes attribute ID is 0 + set_node_attribute(matter_node, 1, 84, 0, []) + # RvcOperationalState cluster ID is 97, AcceptedCommandList attribute ID is 65529 + set_node_attribute(matter_node, 1, 97, 65529, []) + await trigger_subscription_callback(hass, matter_client) + + # test start action fails when no supported run modes + with pytest.raises( + HomeAssistantError, + match="No supported run mode found to start the vacuum cleaner", + ): + await hass.services.async_call( + "vacuum", + "start", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + # test stop action fails when no supported run modes + with pytest.raises( + HomeAssistantError, + match="No supported run mode found to stop the vacuum cleaner", + ): + await hass.services.async_call( + "vacuum", + "stop", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + # Ensure no commands were sent to the device + assert matter_client.send_device_command.call_count == 0 diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index b5e25d9fe50..e109a9626d3 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -23,13 +23,15 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="llm_hass_api") -def llm_hass_api_fixture() -> str: +def llm_hass_api_fixture() -> list[str]: """Fixture for the config entry llm_hass_api.""" - return llm.LLM_API_ASSIST + return [llm.LLM_API_ASSIST] @pytest.fixture(name="config_entry") -def mock_config_entry(hass: HomeAssistant, llm_hass_api: str) -> MockConfigEntry: +def mock_config_entry( + hass: HomeAssistant, llm_hass_api: str | list[str] +) -> MockConfigEntry: """Fixture to load the integration.""" config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/mcp_server/test_config_flow.py b/tests/components/mcp_server/test_config_flow.py index 3b9f5bee663..52bbc26873c 100644 --- a/tests/components/mcp_server/test_config_flow.py +++ b/tests/components/mcp_server/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.data_entry_flow import FlowResultType "params", [ {}, - {CONF_LLM_HASS_API: "assist"}, + {CONF_LLM_HASS_API: ["assist"]}, ], ) async def test_form( @@ -38,4 +38,33 @@ async def test_form( assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Assist" assert len(mock_setup_entry.mock_calls) == 1 - assert result["data"] == {CONF_LLM_HASS_API: "assist"} + assert result["data"] == {CONF_LLM_HASS_API: ["assist"]} + + +@pytest.mark.parametrize( + ("params", "errors"), + [ + ({CONF_LLM_HASS_API: []}, {CONF_LLM_HASS_API: "llm_api_required"}), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + params: dict[str, Any], + errors: dict[str, str], +) -> None: + """Test we get the errors on invalid user input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + params, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == errors diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 61cd1a4dd02..e1c8801f51b 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -194,7 +194,7 @@ async def test_http_sse_multiple_config_entries( """ config_entry = MockConfigEntry( - domain="mcp_server", data={CONF_LLM_HASS_API: "llm-api-id"} + domain="mcp_server", data={CONF_LLM_HASS_API: ["llm-api-id"]} ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/mealie/conftest.py b/tests/components/mealie/conftest.py index 8e724e4d8ea..422b1c3de44 100644 --- a/tests/components/mealie/conftest.py +++ b/tests/components/mealie/conftest.py @@ -8,6 +8,7 @@ from aiomealie import ( Mealplan, MealplanResponse, Recipe, + RecipesResponse, ShoppingItemsResponse, ShoppingListsResponse, Statistics, @@ -63,6 +64,8 @@ def mock_mealie_client() -> Generator[AsyncMock]: ) recipe = Recipe.from_json(load_fixture("get_recipe.json", DOMAIN)) client.get_recipe.return_value = recipe + recipes = RecipesResponse.from_json(load_fixture("get_recipes.json", DOMAIN)) + client.get_recipes.return_value = recipes client.import_recipe.return_value = recipe client.get_shopping_lists.return_value = ShoppingListsResponse.from_json( load_fixture("get_shopping_lists.json", DOMAIN) diff --git a/tests/components/mealie/fixtures/get_recipe.json b/tests/components/mealie/fixtures/get_recipe.json index a5ccd1876e5..7e42986ebdc 100644 --- a/tests/components/mealie/fixtures/get_recipe.json +++ b/tests/components/mealie/fixtures/get_recipe.json @@ -63,8 +63,6 @@ "unit": null, "food": null, "note": "130g dark couverture chocolate (min. 55% cocoa content)", - "isFood": true, - "disableAmount": false, "display": "1 130g dark couverture chocolate (min. 55% cocoa content)", "title": null, "originalText": null, @@ -87,8 +85,6 @@ "unit": null, "food": null, "note": "150g softened butter", - "isFood": true, - "disableAmount": false, "display": "1 150g softened butter", "title": null, "originalText": null, diff --git a/tests/components/mealie/fixtures/get_recipes.json b/tests/components/mealie/fixtures/get_recipes.json new file mode 100644 index 00000000000..8ee91a1aa0e --- /dev/null +++ b/tests/components/mealie/fixtures/get_recipes.json @@ -0,0 +1,1692 @@ +{ + "page": 1, + "per_page": 50, + "total": 662, + "total_pages": 14, + "items": [ + { + "id": "e82f5449-c33b-437c-b712-337587199264", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "tu6y", + "slug": "tu6y", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T11:10:14.866359", + "createdAt": "2024-01-21T11:10:14.880721", + "updateAt": "2024-01-21T11:10:14.880723", + "lastMade": null + }, + { + "id": "f79f7e9d-4b58-4930-a586-2b127f16ee34", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)", + "slug": "eukole-makaronada-me-kephtedakia-ston-phourno-1", + "image": "En9o", + "recipeYield": "6 servings", + "totalTime": null, + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "50 Minutes", + "description": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T09:08:58.056854", + "createdAt": "2024-01-21T09:08:58.059401", + "updateAt": "2024-01-21T09:08:58.059403", + "lastMade": null + }, + { + "id": "90097c8b-9d80-468a-b497-73957ac0cd8b", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Patates douces au four (1)", + "slug": "patates-douces-au-four-1", + "image": "aAhk", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T10:27:39.409746", + "createdAt": "2024-01-21T09:08:53.846294", + "updateAt": "2024-01-21T09:08:53.846295", + "lastMade": null + }, + { + "id": "98845807-9365-41fd-acd1-35630b468c27", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Sweet potatoes", + "slug": "sweet-potatoes", + "image": "kdhm", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T10:28:05.977615", + "createdAt": "2024-01-21T09:08:53.846294", + "updateAt": "2024-01-21T09:08:53.846295", + "lastMade": null + }, + { + "id": "40c227e0-3c7e-41f7-866d-5de04eaecdd7", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο", + "slug": "eukole-makaronada-me-kephtedakia-ston-phourno", + "image": "tNbG", + "recipeYield": "6 servings", + "totalTime": null, + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "50 Minutes", + "description": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T09:06:44.015829", + "createdAt": "2024-01-21T09:06:44.019650", + "updateAt": "2024-01-21T09:06:44.019653", + "lastMade": null + }, + { + "id": "9c7b8aee-c93c-4b1b-ab48-2625d444743a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Boeuf bourguignon : la vraie recette (2)", + "slug": "boeuf-bourguignon-la-vraie-recette-2", + "image": "nj5M", + "recipeYield": "4 servings", + "totalTime": "5 Hours", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": "4 Hours", + "description": "bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre", + "recipeCategory": [], + "tags": [ + { + "id": "01c2f4ac-54ce-49bc-9bd7-8a49f353a3a4", + "name": "Poivre", + "slug": "poivre" + }, + { + "id": "90a26cea-a8a1-41a1-9e8c-e94e3c40f7a7", + "name": "Sel", + "slug": "sel" + }, + { + "id": "d7b01a4b-5206-4bd2-b9c4-d13b95ca0edb", + "name": "Beurre", + "slug": "beurre" + }, + { + "id": "304faaf8-13ec-4537-91f3-9f39a3585545", + "name": "Facile", + "slug": "facile" + }, + { + "id": "6508fb05-fb60-4bed-90c4-584bd6d74cb5", + "name": "Daube", + "slug": "daube" + }, + { + "id": "18ff59b6-b599-456a-896b-4b76448b08ca", + "name": "Bourguignon", + "slug": "bourguignon" + }, + { + "id": "685a0d90-8de4-494e-8eb8-68e7f5d5ffbe", + "name": "Vin Rouge", + "slug": "vin-rouge" + }, + { + "id": "5dedc8b5-30f5-4d6e-875f-34deefd01883", + "name": "Oignon", + "slug": "oignon" + }, + { + "id": "065b79e0-6276-4ebb-9428-7018b40c55bb", + "name": "Bouquet Garni", + "slug": "bouquet-garni" + }, + { + "id": "d858b1d9-2ca1-46d4-acc2-3d03f991f03f", + "name": "Moyen", + "slug": "moyen" + }, + { + "id": "bded0bd8-8d41-4ec5-ad73-e0107fb60908", + "name": "Boeuf Bourguignon : La Vraie Recette", + "slug": "boeuf-bourguignon-la-vraie-recette" + }, + { + "id": "7f99b04f-914a-408b-a057-511ca1125734", + "name": "Carotte", + "slug": "carotte" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:45:28.780361", + "createdAt": "2024-01-21T08:45:28.782322", + "updateAt": "2024-01-21T08:45:28.782324", + "lastMade": null + }, + { + "id": "fc42c7d1-7b0f-4e04-b88a-dbd80b81540b", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Boeuf bourguignon : la vraie recette (1)", + "slug": "boeuf-bourguignon-la-vraie-recette-1", + "image": "rbU7", + "recipeYield": "4 servings", + "totalTime": "5 Hours", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": "4 Hours", + "description": "bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre", + "recipeCategory": [], + "tags": [ + { + "id": "01c2f4ac-54ce-49bc-9bd7-8a49f353a3a4", + "name": "Poivre", + "slug": "poivre" + }, + { + "id": "90a26cea-a8a1-41a1-9e8c-e94e3c40f7a7", + "name": "Sel", + "slug": "sel" + }, + { + "id": "d7b01a4b-5206-4bd2-b9c4-d13b95ca0edb", + "name": "Beurre", + "slug": "beurre" + }, + { + "id": "304faaf8-13ec-4537-91f3-9f39a3585545", + "name": "Facile", + "slug": "facile" + }, + { + "id": "6508fb05-fb60-4bed-90c4-584bd6d74cb5", + "name": "Daube", + "slug": "daube" + }, + { + "id": "18ff59b6-b599-456a-896b-4b76448b08ca", + "name": "Bourguignon", + "slug": "bourguignon" + }, + { + "id": "685a0d90-8de4-494e-8eb8-68e7f5d5ffbe", + "name": "Vin Rouge", + "slug": "vin-rouge" + }, + { + "id": "5dedc8b5-30f5-4d6e-875f-34deefd01883", + "name": "Oignon", + "slug": "oignon" + }, + { + "id": "065b79e0-6276-4ebb-9428-7018b40c55bb", + "name": "Bouquet Garni", + "slug": "bouquet-garni" + }, + { + "id": "d858b1d9-2ca1-46d4-acc2-3d03f991f03f", + "name": "Moyen", + "slug": "moyen" + }, + { + "id": "bded0bd8-8d41-4ec5-ad73-e0107fb60908", + "name": "Boeuf Bourguignon : La Vraie Recette", + "slug": "boeuf-bourguignon-la-vraie-recette" + }, + { + "id": "7f99b04f-914a-408b-a057-511ca1125734", + "name": "Carotte", + "slug": "carotte" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:43:36.105722", + "createdAt": "2024-01-21T08:43:36.108116", + "updateAt": "2024-01-21T08:43:36.108118", + "lastMade": null + }, + { + "id": "89e63d72-7a51-4cef-b162-2e45035d0a91", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Veganes Marmor-Bananenbrot mit Erdnussbutter", + "slug": "veganes-marmor-bananenbrot-mit-erdnussbutter", + "image": "JSp3", + "recipeYield": "14 servings", + "totalTime": null, + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "55 Minutes", + "description": "Dieses einfache vegane Erdnussbutter-Schoko-Marmor-Bananenbrot Rezept enthält kein Öl und keinen raffiniernten Zucker, ist aber so fluffig, weich, saftig und lecker wie ein Kuchen! Zubereitet mit vielen gesunden Bananen, gelingt es auch glutenfrei und eignet sich perfekt zum Frühstück, als Dessert oder Snack für Zwischendurch!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:28:11.008440", + "createdAt": "2024-01-21T08:28:11.011427", + "updateAt": "2024-01-21T08:28:11.011428", + "lastMade": null + }, + { + "id": "eab64457-97ba-4d6c-871c-cb1c724ccb51", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin", + "slug": "pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin", + "image": "9QMh", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Es ist kein Geheimnis: Ich mag es gerne schnell und einfach. Und ich liebe Pasta! Deshalb habe ich mich vor ein paar Wochen auf die Suche nach der perfekten, schnellen Tomatensoße gemacht. Es muss da draußen doch irgendein Rezept geben, das (fast) genauso schnell zuzubereiten ist, wie Miracoli und dabei aber das schöne Gefühl hinterlässt, ...", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:24:50.952774", + "createdAt": "2024-01-21T08:24:50.955843", + "updateAt": "2024-01-21T08:24:50.955845", + "lastMade": null + }, + { + "id": "12439e3d-3c1c-4dcc-9c6e-4afcea2a0542", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test123", + "slug": "test123", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:00:02.755328", + "createdAt": "2024-01-21T08:00:02.757103", + "updateAt": "2024-01-21T08:00:02.757105", + "lastMade": null + }, + { + "id": "6567f6ec-e410-49cb-a1a5-d08517184e78", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Bureeto", + "slug": "bureeto", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:37:39.940578", + "createdAt": "2024-01-21T07:37:39.942535", + "updateAt": "2024-01-21T07:37:39.942537", + "lastMade": null + }, + { + "id": "f7737d17-161c-4008-88d4-dd2616778cd0", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Subway Double Cookies", + "slug": "subway-double-cookies", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:34:53.944858", + "createdAt": "2024-01-21T07:34:53.946852", + "updateAt": "2024-01-21T07:34:53.946854", + "lastMade": null + }, + { + "id": "1904b717-4a8b-4de9-8909-56958875b5f4", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "qwerty12345", + "slug": "qwerty12345", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:37:55.795675", + "createdAt": "2024-01-21T07:28:05.395272", + "updateAt": "2024-01-21T07:28:05.395274", + "lastMade": null + }, + { + "id": "8bdd3656-5e7e-45d3-a3c4-557390846a22", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Cheeseburger Sliders (Easy, 30-min Recipe)", + "slug": "cheeseburger-sliders-easy-30-min-recipe", + "image": "beGq", + "recipeYield": "24 servings", + "totalTime": "30 Minutes", + "prepTime": "8 Minutes", + "cookTime": null, + "performTime": "22 Minutes", + "description": "Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.", + "recipeCategory": [], + "tags": [ + { + "id": "7a4ca427-642f-4428-8dc7-557ea9c8d1b4", + "name": "Cheeseburger Sliders", + "slug": "cheeseburger-sliders" + }, + { + "id": "941558d2-50d5-4c9d-8890-a0258f18d493", + "name": "Sliders", + "slug": "sliders" + } + ], + "tools": [], + "rating": 5, + "orgURL": "https://natashaskitchen.com/cheeseburger-sliders/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:43:24.261010", + "createdAt": "2024-01-21T06:49:35.466777", + "updateAt": "2024-01-21T06:49:35.466778", + "lastMade": "2024-01-22T04:59:59" + }, + { + "id": "8a30d31d-aa14-411e-af0c-6b61a94f5291", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "meatloaf", + "slug": "meatloaf", + "image": null, + "recipeYield": "4", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T06:37:09.426467", + "createdAt": "2024-01-21T06:36:57.645658", + "updateAt": "2024-01-21T06:37:09.428351", + "lastMade": null + }, + { + "id": "f2f7880b-1136-436f-91b7-129788d8c117", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Richtig rheinischer Sauerbraten", + "slug": "richtig-rheinischer-sauerbraten", + "image": "kCBh", + "recipeYield": "4 servings", + "totalTime": "3 Hours 20 Minutes", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": "2 Hours 20 Minutes", + "description": "Richtig rheinischer Sauerbraten - Rheinischer geht's nicht! Über 536 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": 3, + "orgURL": "https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T05:37:55.419788", + "createdAt": "2024-01-21T05:24:03.402973", + "updateAt": "2024-01-21T05:37:55.422471", + "lastMade": null + }, + { + "id": "cf634591-0f82-4254-8e00-2f7e8b0c9022", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Orientalischer Gemüse-Hähnchen Eintopf", + "slug": "orientalischer-gemuse-hahnchen-eintopf", + "image": "kpBx", + "recipeYield": "6 servings", + "totalTime": "35 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Orientalischer Gemüse-Hähnchen Eintopf. Über 164 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!", + "recipeCategory": [], + "tags": [ + { + "id": "518f3081-a919-4c80-9cad-75ffbd0e73d3", + "name": "Gemüse", + "slug": "gemuse" + }, + { + "id": "a3fff625-1902-4112-b169-54aec4f52ea7", + "name": "Hauptspeise", + "slug": "hauptspeise" + }, + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "1f87d43d-7d9d-4806-993a-fdb89117d64e", + "name": "Fleisch", + "slug": "fleisch" + }, + { + "id": "7caa64df-c65d-4fb0-9075-b788e6a05e1d", + "name": "Geflügel", + "slug": "geflugel" + }, + { + "id": "38d18d57-d817-491e-94f8-da923d2c540e", + "name": "Eintopf", + "slug": "eintopf" + }, + { + "id": "398fbd98-4175-4652-92a4-51e55482dc9b", + "name": "Schmoren", + "slug": "schmoren" + }, + { + "id": "ec303c13-a4f7-4de3-8a4f-d13b72ddd500", + "name": "Hülsenfrüchte", + "slug": "hulsenfruchte" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:58:54.661618", + "createdAt": "2024-01-21T04:58:54.665601", + "updateAt": "2024-01-21T04:58:54.665603", + "lastMade": null + }, + { + "id": "05208856-d273-4cc9-bcfa-e0215d57108d", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test 20240121", + "slug": "test-20240121", + "image": null, + "recipeYield": "4", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:56:20.569413", + "createdAt": "2024-01-21T04:55:49.820247", + "updateAt": "2024-01-21T04:56:20.571564", + "lastMade": null + }, + { + "id": "145eeb05-781a-4eb0-a656-afa8bc8c0164", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Loempia bowl", + "slug": "loempia-bowl", + "image": "McEx", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Zet in 20 minuten deze lekkere loempia bowl in elkaar. Makkelijk, snel en weer eens wat anders. Lekker met prei, sojasaus en kipgehakt.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.lekkerensimpel.com/loempia-bowl/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:39:48.558572", + "createdAt": "2024-01-21T04:39:48.560422", + "updateAt": "2024-01-21T04:39:48.560424", + "lastMade": null + }, + { + "id": "5c6532aa-ad84-424c-bc05-c32d50430fe4", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "5 Ingredient Chocolate Mousse", + "slug": "5-ingredient-chocolate-mousse", + "image": "bzqo", + "recipeYield": "6 servings", + "totalTime": null, + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": null, + "description": "Chocolate Mousse with Aquafaba, to make the fluffiest of mousses. Whip up this dessert in literally five minutes and chill in the fridge until you're ready to serve!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://thehappypear.ie/aquafaba-chocolate-mousse/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T06:06:26.305680", + "createdAt": "2024-01-21T04:14:34.624708", + "updateAt": "2024-01-21T06:06:26.308017", + "lastMade": null + }, + { + "id": "f2e684f2-49e0-45ee-90de-951344472f1c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Der perfekte Pfannkuchen - gelingt einfach immer", + "slug": "der-perfekte-pfannkuchen-gelingt-einfach-immer", + "image": "KGK6", + "recipeYield": "4 servings", + "totalTime": "15 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "10 Minutes", + "description": "Der perfekte Pfannkuchen - gelingt einfach immer - von Kindern geliebt und auch für Kochneulinge super geeignet. Über 2529 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + "recipeCategory": [], + "tags": [ + { + "id": "4ec445c6-fc2f-4a1e-b666-93435a46ec42", + "name": "Schnell", + "slug": "schnell" + }, + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "66bc0f60-ff95-44e4-afef-8437b2c2d9af", + "name": "Backen", + "slug": "backen" + }, + { + "id": "48d2a71c-ed17-4c07-bf9f-bc9216936f54", + "name": "Kuchen", + "slug": "kuchen" + }, + { + "id": "b2821b25-94ea-4576-b488-276331b3d76e", + "name": "Kinder", + "slug": "kinder" + }, + { + "id": "fee5e626-792c-479d-a265-81a0029047f2", + "name": "Mehlspeisen", + "slug": "mehlspeisen" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:06:40.503968", + "createdAt": "2024-01-21T04:04:43.296547", + "updateAt": "2024-01-21T04:06:40.506886", + "lastMade": null + }, + { + "id": "cf239441-b75d-4dea-a48e-9d99b7cb5842", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Dinkel-Sauerteigbrot", + "slug": "dinkel-sauerteigbrot", + "image": "yNDq", + "recipeYield": "1", + "totalTime": "24h", + "prepTime": "1h", + "cookTime": null, + "performTime": "35min", + "description": "Für alle Liebhaber von Dinkel ist dieses Dinkel-Sauerteigbrot ein absolutes Muss. Aussen knusprig und innen herrlich feucht und grossporig.", + "recipeCategory": [ + { + "id": "6d54ca14-eb71-4d3a-933d-5e88f68edb68", + "name": "Brot", + "slug": "brot" + } + ], + "tags": [ + { + "id": "0f80c5d5-d1ee-41ac-a949-54a76b446459", + "name": "Sourdough", + "slug": "sourdough" + } + ], + "tools": [ + { + "id": "1170e609-20d3-45b8-b0c7-3a4cfa614e88", + "name": "Backofen", + "slug": "backofen", + "onHand": false + } + ], + "rating": null, + "orgURL": "https://www.besondersgut.ch/dinkel-sauerteigbrot/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T03:57:41.588112", + "createdAt": "2024-01-21T03:44:30.512149", + "updateAt": "2024-01-21T03:44:30.512151", + "lastMade": null + }, + { + "id": "2673eb90-6d78-4b95-af36-5db8c8a6da37", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test 234234", + "slug": "test-234234", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:07:55.643655", + "createdAt": "2024-01-21T03:14:59.852966", + "updateAt": "2024-01-21T04:07:55.646291", + "lastMade": null + }, + { + "id": "0a723c54-af53-40e9-a15f-c87aae5ac688", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test 243", + "slug": "test-243", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T02:20:32.570339", + "createdAt": "2024-01-21T02:20:32.572744", + "updateAt": "2024-01-21T02:20:32.572746", + "lastMade": null + }, + { + "id": "9d553779-607e-471b-acf3-84e6be27b159", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Einfacher Nudelauflauf mit Brokkoli", + "slug": "einfacher-nudelauflauf-mit-brokkoli", + "image": "nOPT", + "recipeYield": "4 servings", + "totalTime": "35 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T03:04:25.718367", + "createdAt": "2024-01-21T02:13:11.323363", + "updateAt": "2024-01-21T03:04:25.721489", + "lastMade": null + }, + { + "id": "9d3cb303-a996-4144-948a-36afaeeef554", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Tarta cytrynowa z bezą", + "slug": "tarta-cytrynowa-z-beza", + "image": "vxuL", + "recipeYield": "8 servings", + "totalTime": "1 Hour", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": null, + "description": "Tarta cytrynowa z bezą\r\nLekko kwaśna masa cytrynowa, która równoważy słodycz bezy – jeśli to brzmi jak ciasto, które chętnie zjesz na deser, wypróbuj nasz przepis! Tarta z bezą i masą cytrynową nawiązuje do kuchni francuskiej, znanej z wyśmienitych quiche i tart. Tym razem proponujemy ją w wersji na słodko.\r\nDla kogo?\r\nLubisz ciasta o delikatnym, kruchym spodzie? Posmakuje ci tarta cytrynowa z bezą. Przepis jest wprost stworzony dla miłośników lekko cierpkiego smaku cytrusów w wypiekach. Tarta cytrynowa z bezą zdecydowanie nie jest mdłym ciastem!\r\nNa jaką okazję?\r\nNa rodzinnym stole, zamiast zwykłego sernika lub ciasta czekoladowego, może stanąć właśnie tarta cytrynowa z bezą. Przepis ten skradnie serce twojej przyjaciółki lub przyjaciela, którego zaprosisz na herbatę i ciasto. Naszym zdaniem ma też dużą szansę stać się hitem urodzinowej imprezy, gdy pojawi się tuż obok tortu. Tarta cytrynowa z bezą smakuje doskonale w okresie świątecznym – upiecz ją na Wielkanoc oprócz tradycyjnego mazurka i baby.\r\nCzy wiesz, że?\r\nZastanawiasz się, czy kupione kilka dni temu cytryny możesz przeznaczyć do przepisu na tartę? Jest wiele sposobów na przedłużenie ich świeżości. Niektórzy trzymają je w lodówce, w torebce zamykanej strunowo. Ciekawostka: im mocniej pachnie cytryna, tym kwaśniejsza będzie w smaku.\r\nDla urozmaicenia:\r\nMartwisz się o to, czy każda warstwa tarty odpowiednio się upiecze? Mamy na to sposób. Piecz ją w piekarniku bez termoobiegu, ustawionym na grzanie góra–dół.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:27:12.082247", + "createdAt": "2024-01-21T01:27:12.088594", + "updateAt": "2024-01-21T01:27:12.088596", + "lastMade": null + }, + { + "id": "77f05a49-e869-4048-aa62-0d8a1f5a8f1c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Martins test Recipe", + "slug": "martins-test-recipe", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:26:38.793372", + "createdAt": "2024-01-21T01:26:38.802872", + "updateAt": "2024-01-21T01:26:38.802874", + "lastMade": null + }, + { + "id": "75a90207-9c10-4390-a265-c47a4b67fd69", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Muffinki czekoladowe", + "slug": "muffinki-czekoladowe", + "image": "xP1Q", + "recipeYield": "12", + "totalTime": null, + "prepTime": "25 Minutes", + "cookTime": null, + "performTime": "30 Minutes", + "description": "Muffinki czekoladowe to przepyszny i bardzo prosty w przygotowaniu mini deser pieczony w papilotkach. Przepis na najlepsze, bardzo wilgotne i puszyste muffinki czekoladowe polecam każdemu miłośnikowi czekolady.", + "recipeCategory": [], + "tags": [ + { + "id": "ed2eed99-1285-4507-b5cb-b3047d64855c", + "name": "Muffinki Czekoladowe", + "slug": "muffinki-czekoladowe" + }, + { + "id": "e94d5223-5337-4e1b-b36e-7968c8823176", + "name": "Babeczki I Muffiny", + "slug": "babeczki-i-muffiny" + }, + { + "id": "2d06a44a-331a-4922-abb4-8047ee5e7c1c", + "name": "Sylwester", + "slug": "sylwester" + }, + { + "id": "c78edd8c-c96b-43fb-86c0-917ea5a08ac7", + "name": "Wegetariańska", + "slug": "wegetarianska" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://aniagotuje.pl/przepis/muffinki-czekoladowe", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:25:53.529639", + "createdAt": "2024-01-21T01:25:03.838184", + "updateAt": "2024-01-21T01:25:53.534515", + "lastMade": null + }, + { + "id": "4320ba72-377b-4657-8297-dce198f24cdf", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "My Test Recipe", + "slug": "my-test-recipe", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:22:10.331488", + "createdAt": "2024-01-21T01:22:10.361617", + "updateAt": "2024-01-21T01:22:10.361618", + "lastMade": null + }, + { + "id": "98dac844-31ee-426a-b16c-fb62a5dd2816", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "My Test Receipe", + "slug": "my-test-receipe", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:22:10.309993", + "createdAt": "2024-01-21T01:22:10.357806", + "updateAt": "2024-01-21T01:22:10.357807", + "lastMade": null + }, + { + "id": "c3c8f207-c704-415d-81b1-da9f032cf52f", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Patates douces au four", + "slug": "patates-douces-au-four", + "image": "r1ck", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T00:34:57.419501", + "createdAt": "2024-01-21T00:34:57.422137", + "updateAt": "2024-01-21T00:34:57.422139", + "lastMade": null + }, + { + "id": "1edb2f6e-133c-4be0-b516-3c23625a97ec", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Easy Homemade Pizza Dough", + "slug": "easy-homemade-pizza-dough", + "image": "gD94", + "recipeYield": "2 servings", + "totalTime": "2 Hours 30 Minutes", + "prepTime": "2 Hours 15 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Follow these basic instructions for a thick, crisp, and chewy pizza crust at home. The recipe yields enough pizza dough for two 12-inch pizzas and you can freeze half of the dough for later. Close to 2 pounds of dough total.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T22:41:09.255367", + "createdAt": "2024-01-20T22:41:09.258070", + "updateAt": "2024-01-20T22:41:09.258071", + "lastMade": null + }, + { + "id": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "All-American Beef Stew Recipe", + "slug": "all-american-beef-stew-recipe", + "image": "356X", + "recipeYield": "6 servings", + "totalTime": "3 Hours 15 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "3 Hours 10 Minutes", + "description": "This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.seriouseats.com/all-american-beef-stew-recipe", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-21T03:04:45.606075", + "createdAt": "2024-01-20T20:41:29.266390", + "updateAt": "2024-01-21T03:04:45.609563", + "lastMade": null + }, + { + "id": "6530ea6e-401e-4304-8a7a-12162ddf5b9c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", + "slug": "serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce", + "image": "4Sys", + "recipeYield": "4 servings", + "totalTime": "2 Hours 15 Minutes", + "prepTime": "20 Minutes", + "cookTime": null, + "performTime": "55 Minutes", + "description": "This utterly faithful recipe perfectly recreates a New York City halal-cart classic: Chicken and Rice with White Sauce. The chicken is marinated with herbs, lemon, and spices; the rice golden; the sauce, as white and creamy as ever.", + "recipeCategory": [], + "tags": [ + { + "id": "d7aea128-0e7b-4e0c-a236-e500717701bb", + "name": "Rice", + "slug": "rice" + }, + { + "id": "1dd3541c-ed6b-4a25-b829-9a71358409ef", + "name": "Chicken", + "slug": "chicken" + }, + { + "id": "eb871b57-ea46-4cb5-88a5-98064514e593", + "name": "Chicken And Rice", + "slug": "chicken-and-rice" + }, + { + "id": "2b0a0ed2-e799-4ab2-8a24-d5ce15827a8e", + "name": "Cook The Book", + "slug": "cook-the-book" + }, + { + "id": "e6783087-0cee-4f31-b588-268380f75335", + "name": "Halal", + "slug": "halal" + }, + { + "id": "a2d99845-8bd0-4a2a-9a56-f8a34f51039e", + "name": "Middle Eastern", + "slug": "middle-eastern" + }, + { + "id": "6b7b95b0-b3f8-467f-857d-ef036009d5e1", + "name": "New York City", + "slug": "new-york-city" + }, + { + "id": "6bd6c577-9d00-411f-88de-b8679c37ac58", + "name": "Serious Eats Book", + "slug": "serious-eats-book" + }, + { + "id": "d77a2071-43ae-40b1-854d-ae995a766fba", + "name": "Street Food", + "slug": "street-food" + } + ], + "tools": [], + "rating": 5, + "orgURL": "https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T20:32:14.736668", + "createdAt": "2024-01-20T20:25:43.655397", + "updateAt": "2024-01-20T20:32:14.740947", + "lastMade": null + }, + { + "id": "c496cf9c-1ece-448a-9d3f-ef772f078a4e", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Schnelle Käsespätzle", + "slug": "schnelle-kasespatzle", + "image": "8goY", + "recipeYield": "4 servings", + "totalTime": "40 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "30 Minutes", + "description": "Schnelle Käsespätzle. Über 1201 Bewertungen und für sehr gut befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T18:31:51.652135", + "createdAt": "2024-01-20T18:31:51.654414", + "updateAt": "2024-01-20T18:31:51.654415", + "lastMade": null + }, + { + "id": "49aa6f42-6760-4adf-b6cd-59592da485c3", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "taco", + "slug": "taco", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T17:25:27.960087", + "createdAt": "2024-01-20T17:25:27.961639", + "updateAt": "2024-01-20T17:25:27.961641", + "lastMade": null + }, + { + "id": "6402a253-2baa-460d-bf4f-b759bb655588", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Vodkapasta", + "slug": "vodkapasta", + "image": "z8BB", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.ica.se/recept/vodkapasta-729011/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-21T01:58:25.398326", + "createdAt": "2024-01-20T15:35:35.492234", + "updateAt": "2024-01-21T01:58:25.400556", + "lastMade": "2024-01-21T22:59:59" + }, + { + "id": "4f54e9e1-f21d-40ec-a135-91e633dfb733", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Vodkapasta2", + "slug": "vodkapasta2", + "image": "Nqpz", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.ica.se/recept/vodkapasta-729011/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T17:35:32.077132", + "createdAt": "2024-01-20T15:35:35.492234", + "updateAt": "2024-01-20T17:24:19.620474", + "lastMade": "2024-01-21T04:59:59" + }, + { + "id": "e1a3edb0-49a0-49a3-83e3-95554e932670", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Rub", + "slug": "rub", + "image": null, + "recipeYield": "1", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:55:15.172744", + "createdAt": "2024-01-20T13:53:34.298477", + "updateAt": "2024-01-20T13:55:15.174780", + "lastMade": null + }, + { + "id": "1a0f4e54-db5b-40f1-ab7e-166dab5f6523", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Banana Bread Chocolate Chip Cookies", + "slug": "banana-bread-chocolate-chip-cookies", + "image": "03XS", + "recipeYield": "", + "totalTime": null, + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Tender and moist, these chocolate chip cookies were a HUGE hit in the Test Kitchen. They're like banana bread in a cookie form. Outside, there are crisp edges like a cookie. Inside, though, it's soft like banana bread. We opted to add chocolate chips and nuts. It's a classic flavor combination in banana bread and works just as well in these cookies.", + "recipeCategory": [], + "tags": [ + { + "id": "6a59e597-9aff-4716-961f-f236b93c34cc", + "name": "Cookies", + "slug": "cookies" + }, + { + "id": "1249f351-4b45-455d-b5f0-64eb0124a41e", + "name": "Banana", + "slug": "banana" + }, + { + "id": "81a446b9-4d8d-451d-a472-486987fad85a", + "name": "Bread", + "slug": "bread" + }, + { + "id": "c2536221-b1c3-4402-a104-46c632663748", + "name": "Chocolate Chip", + "slug": "chocolate-chip" + }, + { + "id": "c026c67f-0211-419f-9db8-7cd4c7608589", + "name": "Cookie", + "slug": "cookie" + }, + { + "id": "2f9e0bf5-02e2-4bdc-9b5d-a16d2fec885b", + "name": "American", + "slug": "american" + }, + { + "id": "2a7c5386-5d26-44fa-8a08-81747ee7f132", + "name": "Bake", + "slug": "bake" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:52:21.817496", + "createdAt": "2024-01-20T13:51:46.727976", + "updateAt": "2024-01-20T13:52:21.821329", + "lastMade": null + }, + { + "id": "447acae6-3424-4c16-8c26-c09040ad8041", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Cauliflower Bisque Recipe with Cheddar Cheese", + "slug": "cauliflower-bisque-recipe-with-cheddar-cheese", + "image": "KuXV", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Hello friends, today I'm going to share with you how to make a delicious soup/bisque. A Cauliflower Bisques Recipe with Cheddar Cheese. One of my favorite soups to make when its cold outside. We will be continuing the soup collection so let me know what you think in the comments below!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:45:10.848270", + "createdAt": "2024-01-20T13:44:59.990057", + "updateAt": "2024-01-20T13:45:10.851647", + "lastMade": null + }, + { + "id": "864136a3-27b0-4f3b-a90f-486f42d6df7a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Prova ", + "slug": "prova", + "image": null, + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:44:41.788771", + "createdAt": "2024-01-20T13:42:56.178473", + "updateAt": "2024-01-20T13:42:56.178475", + "lastMade": null + }, + { + "id": "c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "pate au beurre (1)", + "slug": "pate-au-beurre-1", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:17:47.586659", + "createdAt": "2024-01-20T13:17:47.592852", + "updateAt": "2024-01-20T13:17:47.592854", + "lastMade": null + }, + { + "id": "d01865c3-0f18-4e8d-84c0-c14c345fdf9c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "pate au beurre", + "slug": "pate-au-beurre", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:16:49.702039", + "createdAt": "2024-01-20T13:16:49.704498", + "updateAt": "2024-01-20T13:16:49.704500", + "lastMade": null + }, + { + "id": "2cec2bb2-19b6-40b8-a36c-1a76ea29c517", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Sous Vide Cheesecake Recipe", + "slug": "sous-vide-cheesecake-recipe", + "image": "tmwm", + "recipeYield": "4 servings", + "totalTime": "2 Hours 10 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "1 Hour 30 Minutes", + "description": "Individual foolproof mason jar cheesecakes with strawberry compote and a Graham cracker crumble topping. Foolproof, simple, and delicious.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://saltpepperskillet.com/recipes/sous-vide-cheesecake/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:07:19.939939", + "createdAt": "2024-01-20T13:07:19.946260", + "updateAt": "2024-01-20T13:07:19.946263", + "lastMade": null + }, + { + "id": "8e0e4566-9caf-4c2e-a01c-dcead23db86b", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "The Bomb Mini Cheesecakes", + "slug": "the-bomb-mini-cheesecakes", + "image": "xCYc", + "recipeYield": "10 servings", + "totalTime": "1 Hour 30 Minutes", + "prepTime": "30 Minutes", + "cookTime": null, + "performTime": null, + "description": "This is a variation of the several cheese cake recipes that have been used for sous vide. These make a fabulous 4oz cheese cake for dessert. Garnish with a raspberry or blackberry and impress your family and friends. They’ll keep great in the fridge for a week easily.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:05:24.037000", + "createdAt": "2024-01-20T13:05:24.039558", + "updateAt": "2024-01-20T13:05:24.039560", + "lastMade": null + }, + { + "id": "a051eafd-9712-4aee-a8e5-0cd10a6772ee", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Tagliatelle al Salmone", + "slug": "tagliatelle-al-salmone", + "image": "qzaN", + "recipeYield": "4 servings", + "totalTime": "25 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Tagliatelle al Salmone - wie beim Italiener. Über 1568 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + "recipeCategory": [], + "tags": [ + { + "id": "518f3081-a919-4c80-9cad-75ffbd0e73d3", + "name": "Gemüse", + "slug": "gemuse" + }, + { + "id": "a3fff625-1902-4112-b169-54aec4f52ea7", + "name": "Hauptspeise", + "slug": "hauptspeise" + }, + { + "id": "4ec445c6-fc2f-4a1e-b666-93435a46ec42", + "name": "Schnell", + "slug": "schnell" + }, + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "6f349f84-655b-4740-8fa6-ed2716f17df7", + "name": "Gekocht", + "slug": "gekocht" + }, + { + "id": "77bc190f-dc6d-440b-aa82-f32bfe836018", + "name": "Europa", + "slug": "europa" + }, + { + "id": "7997c911-14ee-4e76-9895-debad7949ae2", + "name": "Pasta", + "slug": "pasta" + }, + { + "id": "04d2aea8-fc9a-4f9b-9a87-8f15189ab6f9", + "name": "Nudeln", + "slug": "nudeln" + }, + { + "id": "c56cd402-3ac7-479e-b96c-d4b64d177dd3", + "name": "Fisch", + "slug": "fisch" + }, + { + "id": "88015586-0885-4397-9098-039ae1109cd1", + "name": "Italien", + "slug": "italien" + }, + { + "id": "024b30ca-53cb-4243-ba6b-d830610f2f48", + "name": "Saucen", + "slug": "saucen" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:02:16.760030", + "createdAt": "2024-01-20T13:02:16.763188", + "updateAt": "2024-01-20T13:02:16.763189", + "lastMade": null + }, + { + "id": "093d51e9-0823-40ad-8e0e-a1d5790dd627", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Death by Chocolate", + "slug": "death-by-chocolate", + "image": "K9qP", + "recipeYield": "1 serving", + "totalTime": null, + "prepTime": "25 Minutes", + "cookTime": null, + "performTime": "25 Minutes", + "description": "Hier ist der Name Programm: Den \"Tod durch Schokolade\" müsst ihr zwar hoffentlich nicht erleiden, aber Chocoholics werden diesen Kuchen lieben!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T12:58:50.926224", + "createdAt": "2024-01-20T12:58:50.928810", + "updateAt": "2024-01-20T12:58:50.928812", + "lastMade": null + }, + { + "id": "2d1f62ec-4200-4cfd-987e-c75755d7607c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Palak Dal Rezept aus Indien", + "slug": "palak-dal-rezept-aus-indien", + "image": "jKQ3", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Palak Dal ist in Grunde genommen Spinat (Palak) mit Linsen oder anderen Hülsenfrüchten (Dal) vom indischen Subkontinent. Es kommen noch Zwiebeln, Tomaten und einige indische Gewürze dazu. Damit ist das Palak Dal ein super einfaches und zugleich veganes indisches Rezept. Es schmeckt hervorragend mit Naan-Brot und etwas gewürztem Joghurt.", + "recipeCategory": [], + "tags": [ + { + "id": "38d18d57-d817-491e-94f8-da923d2c540e", + "name": "Eintopf", + "slug": "eintopf" + }, + { + "id": "43f12acf-a8df-45bd-b33d-20bfe7a7e607", + "name": "Indisch", + "slug": "indisch" + }, + { + "id": "ede834ac-ab8f-4c79-8a42-dfa0270fd18b", + "name": "Linsen", + "slug": "linsen" + }, + { + "id": "2b6283e2-b8e0-4b3d-90d9-66f322ca77aa", + "name": "Spinat", + "slug": "spinat" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T12:46:54.570376", + "createdAt": "2024-01-20T12:46:54.573341", + "updateAt": "2024-01-20T12:46:54.573342", + "lastMade": null + }, + { + "id": "973dc36d-1661-49b4-ad2d-0b7191034fb3", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Tortelline - á la Romana", + "slug": "tortelline-a-la-romana", + "image": "rkSn", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": "30 Minutes", + "cookTime": null, + "performTime": null, + "description": "Tortelline - á la Romana. Über 13 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!", + "recipeCategory": [], + "tags": [ + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "7997c911-14ee-4e76-9895-debad7949ae2", + "name": "Pasta", + "slug": "pasta" + }, + { + "id": "04d2aea8-fc9a-4f9b-9a87-8f15189ab6f9", + "name": "Nudeln", + "slug": "nudeln" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:44:42.215472", + "createdAt": "2024-01-20T12:29:47.825708", + "updateAt": "2024-01-20T13:44:42.218635", + "lastMade": "2024-01-21T20:59:59" + } + ], + "next": "/recipes?page=2&perPage=50&orderDirection=desc", + "previous": null +} diff --git a/tests/components/mealie/fixtures/get_shopping_items.json b/tests/components/mealie/fixtures/get_shopping_items.json index 1016440816b..81db48f2e1a 100644 --- a/tests/components/mealie/fixtures/get_shopping_items.json +++ b/tests/components/mealie/fixtures/get_shopping_items.json @@ -9,8 +9,6 @@ "unit": null, "food": null, "note": "Apples", - "isFood": false, - "disableAmount": true, "display": "2 Apples", "shoppingListId": "9ce096fe-ded2-4077-877d-78ba450ab13e", "checked": false, diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index a694c72fcf6..c4d649fcec6 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -383,10 +383,10 @@ 'items': list([ dict({ 'checked': False, - 'disable_amount': True, + 'disable_amount': None, 'display': '2 Apples', 'food_id': None, - 'is_food': False, + 'is_food': None, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', @@ -433,10 +433,10 @@ 'items': list([ dict({ 'checked': False, - 'disable_amount': True, + 'disable_amount': None, 'display': '2 Apples', 'food_id': None, - 'is_food': False, + 'is_food': None, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', @@ -483,10 +483,10 @@ 'items': list([ dict({ 'checked': False, - 'disable_amount': True, + 'disable_amount': None, 'display': '2 Apples', 'food_id': None, - 'is_food': False, + 'is_food': None, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index aada173ffc3..50da06ca005 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'bf1c62fe-4941-4332-9886-e54e88dbdba0', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'v1.10.2', 'via_device_id': None, }) diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 56626c7b5c4..a1cb758098e 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1,4 +1,1242 @@ # serializer version: 1 +# name: test_service_get_recipes[service_data0] + dict({ + 'recipes': dict({ + 'items': list([ + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'tu6y', + 'original_url': None, + 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', + 'recipe_yield': None, + 'slug': 'tu6y', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'En9o', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'aAhk', + 'name': 'Patates douces au four (1)', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kdhm', + 'name': 'Sweet potatoes', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', + 'recipe_yield': '', + 'slug': 'sweet-potatoes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tNbG', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nj5M', + 'name': 'Boeuf bourguignon : la vraie recette (2)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rbU7', + 'name': 'Boeuf bourguignon : la vraie recette (1)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Dieses einfache vegane Erdnussbutter-Schoko-Marmor-Bananenbrot Rezept enthält kein Öl und keinen raffiniernten Zucker, ist aber so fluffig, weich, saftig und lecker wie ein Kuchen! Zubereitet mit vielen gesunden Bananen, gelingt es auch glutenfrei und eignet sich perfekt zum Frühstück, als Dessert oder Snack für Zwischendurch!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'JSp3', + 'name': 'Veganes Marmor-Bananenbrot mit Erdnussbutter', + 'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/', + 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', + 'recipe_yield': '14 servings', + 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Es ist kein Geheimnis: Ich mag es gerne schnell und einfach. Und ich liebe Pasta! Deshalb habe ich mich vor ein paar Wochen auf die Suche nach der perfekten, schnellen Tomatensoße gemacht. Es muss da draußen doch irgendein Rezept geben, das (fast) genauso schnell zuzubereiten ist, wie Miracoli und dabei aber das schöne Gefühl hinterlässt, ...', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '9QMh', + 'name': 'Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin', + 'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/', + 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', + 'recipe_yield': '', + 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test123', + 'original_url': None, + 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', + 'recipe_yield': None, + 'slug': 'test123', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Bureeto', + 'original_url': None, + 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', + 'recipe_yield': None, + 'slug': 'bureeto', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Subway Double Cookies', + 'original_url': None, + 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', + 'recipe_yield': None, + 'slug': 'subway-double-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'qwerty12345', + 'original_url': None, + 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', + 'recipe_yield': None, + 'slug': 'qwerty12345', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'beGq', + 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', + 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_yield': '24 servings', + 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'meatloaf', + 'original_url': None, + 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', + 'recipe_yield': '4', + 'slug': 'meatloaf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Richtig rheinischer Sauerbraten - Rheinischer geht's nicht! Über 536 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kCBh', + 'name': 'Richtig rheinischer Sauerbraten', + 'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html', + 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', + 'recipe_yield': '4 servings', + 'slug': 'richtig-rheinischer-sauerbraten', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Orientalischer Gemüse-Hähnchen Eintopf. Über 164 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kpBx', + 'name': 'Orientalischer Gemüse-Hähnchen Eintopf', + 'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html', + 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', + 'recipe_yield': '6 servings', + 'slug': 'orientalischer-gemuse-hahnchen-eintopf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 20240121', + 'original_url': None, + 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', + 'recipe_yield': '4', + 'slug': 'test-20240121', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Zet in 20 minuten deze lekkere loempia bowl in elkaar. Makkelijk, snel en weer eens wat anders. Lekker met prei, sojasaus en kipgehakt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'McEx', + 'name': 'Loempia bowl', + 'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/', + 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', + 'recipe_yield': '', + 'slug': 'loempia-bowl', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Chocolate Mousse with Aquafaba, to make the fluffiest of mousses. Whip up this dessert in literally five minutes and chill in the fridge until you're ready to serve!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'bzqo', + 'name': '5 Ingredient Chocolate Mousse', + 'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/', + 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', + 'recipe_yield': '6 servings', + 'slug': '5-ingredient-chocolate-mousse', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Der perfekte Pfannkuchen - gelingt einfach immer - von Kindern geliebt und auch für Kochneulinge super geeignet. Über 2529 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KGK6', + 'name': 'Der perfekte Pfannkuchen - gelingt einfach immer', + 'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html', + 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', + 'recipe_yield': '4 servings', + 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Für alle Liebhaber von Dinkel ist dieses Dinkel-Sauerteigbrot ein absolutes Muss. Aussen knusprig und innen herrlich feucht und grossporig.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'yNDq', + 'name': 'Dinkel-Sauerteigbrot', + 'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/', + 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', + 'recipe_yield': '1', + 'slug': 'dinkel-sauerteigbrot', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 234234', + 'original_url': None, + 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', + 'recipe_yield': None, + 'slug': 'test-234234', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 243', + 'original_url': None, + 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', + 'recipe_yield': None, + 'slug': 'test-243', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nOPT', + 'name': 'Einfacher Nudelauflauf mit Brokkoli', + 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_yield': '4 servings', + 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': ''' + Tarta cytrynowa z bezą + Lekko kwaśna masa cytrynowa, która równoważy słodycz bezy – jeśli to brzmi jak ciasto, które chętnie zjesz na deser, wypróbuj nasz przepis! Tarta z bezą i masą cytrynową nawiązuje do kuchni francuskiej, znanej z wyśmienitych quiche i tart. Tym razem proponujemy ją w wersji na słodko. + Dla kogo? + Lubisz ciasta o delikatnym, kruchym spodzie? Posmakuje ci tarta cytrynowa z bezą. Przepis jest wprost stworzony dla miłośników lekko cierpkiego smaku cytrusów w wypiekach. Tarta cytrynowa z bezą zdecydowanie nie jest mdłym ciastem! + Na jaką okazję? + Na rodzinnym stole, zamiast zwykłego sernika lub ciasta czekoladowego, może stanąć właśnie tarta cytrynowa z bezą. Przepis ten skradnie serce twojej przyjaciółki lub przyjaciela, którego zaprosisz na herbatę i ciasto. Naszym zdaniem ma też dużą szansę stać się hitem urodzinowej imprezy, gdy pojawi się tuż obok tortu. Tarta cytrynowa z bezą smakuje doskonale w okresie świątecznym – upiecz ją na Wielkanoc oprócz tradycyjnego mazurka i baby. + Czy wiesz, że? + Zastanawiasz się, czy kupione kilka dni temu cytryny możesz przeznaczyć do przepisu na tartę? Jest wiele sposobów na przedłużenie ich świeżości. Niektórzy trzymają je w lodówce, w torebce zamykanej strunowo. Ciekawostka: im mocniej pachnie cytryna, tym kwaśniejsza będzie w smaku. + Dla urozmaicenia: + Martwisz się o to, czy każda warstwa tarty odpowiednio się upiecze? Mamy na to sposób. Piecz ją w piekarniku bez termoobiegu, ustawionym na grzanie góra–dół. + ''', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'vxuL', + 'name': 'Tarta cytrynowa z bezą', + 'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza', + 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', + 'recipe_yield': '8 servings', + 'slug': 'tarta-cytrynowa-z-beza', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Martins test Recipe', + 'original_url': None, + 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', + 'recipe_yield': None, + 'slug': 'martins-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Muffinki czekoladowe to przepyszny i bardzo prosty w przygotowaniu mini deser pieczony w papilotkach. Przepis na najlepsze, bardzo wilgotne i puszyste muffinki czekoladowe polecam każdemu miłośnikowi czekolady.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xP1Q', + 'name': 'Muffinki czekoladowe', + 'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe', + 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', + 'recipe_yield': '12', + 'slug': 'muffinki-czekoladowe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Recipe', + 'original_url': None, + 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', + 'recipe_yield': None, + 'slug': 'my-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Receipe', + 'original_url': None, + 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', + 'recipe_yield': None, + 'slug': 'my-test-receipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'r1ck', + 'name': 'Patates douces au four', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Follow these basic instructions for a thick, crisp, and chewy pizza crust at home. The recipe yields enough pizza dough for two 12-inch pizzas and you can freeze half of the dough for later. Close to 2 pounds of dough total.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'gD94', + 'name': 'Easy Homemade Pizza Dough', + 'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/', + 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', + 'recipe_yield': '2 servings', + 'slug': 'easy-homemade-pizza-dough', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '356X', + 'name': 'All-American Beef Stew Recipe', + 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_yield': '6 servings', + 'slug': 'all-american-beef-stew-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This utterly faithful recipe perfectly recreates a New York City halal-cart classic: Chicken and Rice with White Sauce. The chicken is marinated with herbs, lemon, and spices; the rice golden; the sauce, as white and creamy as ever.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '4Sys', + 'name': "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", + 'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe', + 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', + 'recipe_yield': '4 servings', + 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Schnelle Käsespätzle. Über 1201 Bewertungen und für sehr gut befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '8goY', + 'name': 'Schnelle Käsespätzle', + 'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html', + 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', + 'recipe_yield': '4 servings', + 'slug': 'schnelle-kasespatzle', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'taco', + 'original_url': None, + 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', + 'recipe_yield': None, + 'slug': 'taco', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'z8BB', + 'name': 'Vodkapasta', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'Nqpz', + 'name': 'Vodkapasta2', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Rub', + 'original_url': None, + 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', + 'recipe_yield': '1', + 'slug': 'rub', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Tender and moist, these chocolate chip cookies were a HUGE hit in the Test Kitchen. They're like banana bread in a cookie form. Outside, there are crisp edges like a cookie. Inside, though, it's soft like banana bread. We opted to add chocolate chips and nuts. It's a classic flavor combination in banana bread and works just as well in these cookies.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '03XS', + 'name': 'Banana Bread Chocolate Chip Cookies', + 'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html', + 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', + 'recipe_yield': '', + 'slug': 'banana-bread-chocolate-chip-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Hello friends, today I'm going to share with you how to make a delicious soup/bisque. A Cauliflower Bisques Recipe with Cheddar Cheese. One of my favorite soups to make when its cold outside. We will be continuing the soup collection so let me know what you think in the comments below!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KuXV', + 'name': 'Cauliflower Bisque Recipe with Cheddar Cheese', + 'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/', + 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', + 'recipe_yield': '', + 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Prova ', + 'original_url': None, + 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', + 'recipe_yield': '', + 'slug': 'prova', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre (1)', + 'original_url': None, + 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', + 'recipe_yield': None, + 'slug': 'pate-au-beurre-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre', + 'original_url': None, + 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', + 'recipe_yield': None, + 'slug': 'pate-au-beurre', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Individual foolproof mason jar cheesecakes with strawberry compote and a Graham cracker crumble topping. Foolproof, simple, and delicious.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tmwm', + 'name': 'Sous Vide Cheesecake Recipe', + 'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/', + 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', + 'recipe_yield': '4 servings', + 'slug': 'sous-vide-cheesecake-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This is a variation of the several cheese cake recipes that have been used for sous vide. These make a fabulous 4oz cheese cake for dessert. Garnish with a raspberry or blackberry and impress your family and friends. They’ll keep great in the fridge for a week easily.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xCYc', + 'name': 'The Bomb Mini Cheesecakes', + 'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes', + 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', + 'recipe_yield': '10 servings', + 'slug': 'the-bomb-mini-cheesecakes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tagliatelle al Salmone - wie beim Italiener. Über 1568 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'qzaN', + 'name': 'Tagliatelle al Salmone', + 'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html', + 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', + 'recipe_yield': '4 servings', + 'slug': 'tagliatelle-al-salmone', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Hier ist der Name Programm: Den "Tod durch Schokolade" müsst ihr zwar hoffentlich nicht erleiden, aber Chocoholics werden diesen Kuchen lieben!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'K9qP', + 'name': 'Death by Chocolate', + 'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html', + 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', + 'recipe_yield': '1 serving', + 'slug': 'death-by-chocolate', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Palak Dal ist in Grunde genommen Spinat (Palak) mit Linsen oder anderen Hülsenfrüchten (Dal) vom indischen Subkontinent. Es kommen noch Zwiebeln, Tomaten und einige indische Gewürze dazu. Damit ist das Palak Dal ein super einfaches und zugleich veganes indisches Rezept. Es schmeckt hervorragend mit Naan-Brot und etwas gewürztem Joghurt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'jKQ3', + 'name': 'Palak Dal Rezept aus Indien', + 'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/', + 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', + 'recipe_yield': '4 servings', + 'slug': 'palak-dal-rezept-aus-indien', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tortelline - á la Romana. Über 13 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rkSn', + 'name': 'Tortelline - á la Romana', + 'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html', + 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', + 'recipe_yield': '4 servings', + 'slug': 'tortelline-a-la-romana', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), + }), + }) +# --- +# name: test_service_get_recipes[service_data1] + dict({ + 'recipes': dict({ + 'items': list([ + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'tu6y', + 'original_url': None, + 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', + 'recipe_yield': None, + 'slug': 'tu6y', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'En9o', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'aAhk', + 'name': 'Patates douces au four (1)', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kdhm', + 'name': 'Sweet potatoes', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', + 'recipe_yield': '', + 'slug': 'sweet-potatoes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tNbG', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nj5M', + 'name': 'Boeuf bourguignon : la vraie recette (2)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rbU7', + 'name': 'Boeuf bourguignon : la vraie recette (1)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Dieses einfache vegane Erdnussbutter-Schoko-Marmor-Bananenbrot Rezept enthält kein Öl und keinen raffiniernten Zucker, ist aber so fluffig, weich, saftig und lecker wie ein Kuchen! Zubereitet mit vielen gesunden Bananen, gelingt es auch glutenfrei und eignet sich perfekt zum Frühstück, als Dessert oder Snack für Zwischendurch!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'JSp3', + 'name': 'Veganes Marmor-Bananenbrot mit Erdnussbutter', + 'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/', + 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', + 'recipe_yield': '14 servings', + 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Es ist kein Geheimnis: Ich mag es gerne schnell und einfach. Und ich liebe Pasta! Deshalb habe ich mich vor ein paar Wochen auf die Suche nach der perfekten, schnellen Tomatensoße gemacht. Es muss da draußen doch irgendein Rezept geben, das (fast) genauso schnell zuzubereiten ist, wie Miracoli und dabei aber das schöne Gefühl hinterlässt, ...', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '9QMh', + 'name': 'Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin', + 'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/', + 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', + 'recipe_yield': '', + 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test123', + 'original_url': None, + 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', + 'recipe_yield': None, + 'slug': 'test123', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Bureeto', + 'original_url': None, + 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', + 'recipe_yield': None, + 'slug': 'bureeto', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Subway Double Cookies', + 'original_url': None, + 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', + 'recipe_yield': None, + 'slug': 'subway-double-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'qwerty12345', + 'original_url': None, + 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', + 'recipe_yield': None, + 'slug': 'qwerty12345', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'beGq', + 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', + 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_yield': '24 servings', + 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'meatloaf', + 'original_url': None, + 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', + 'recipe_yield': '4', + 'slug': 'meatloaf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Richtig rheinischer Sauerbraten - Rheinischer geht's nicht! Über 536 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kCBh', + 'name': 'Richtig rheinischer Sauerbraten', + 'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html', + 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', + 'recipe_yield': '4 servings', + 'slug': 'richtig-rheinischer-sauerbraten', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Orientalischer Gemüse-Hähnchen Eintopf. Über 164 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kpBx', + 'name': 'Orientalischer Gemüse-Hähnchen Eintopf', + 'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html', + 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', + 'recipe_yield': '6 servings', + 'slug': 'orientalischer-gemuse-hahnchen-eintopf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 20240121', + 'original_url': None, + 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', + 'recipe_yield': '4', + 'slug': 'test-20240121', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Zet in 20 minuten deze lekkere loempia bowl in elkaar. Makkelijk, snel en weer eens wat anders. Lekker met prei, sojasaus en kipgehakt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'McEx', + 'name': 'Loempia bowl', + 'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/', + 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', + 'recipe_yield': '', + 'slug': 'loempia-bowl', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Chocolate Mousse with Aquafaba, to make the fluffiest of mousses. Whip up this dessert in literally five minutes and chill in the fridge until you're ready to serve!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'bzqo', + 'name': '5 Ingredient Chocolate Mousse', + 'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/', + 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', + 'recipe_yield': '6 servings', + 'slug': '5-ingredient-chocolate-mousse', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Der perfekte Pfannkuchen - gelingt einfach immer - von Kindern geliebt und auch für Kochneulinge super geeignet. Über 2529 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KGK6', + 'name': 'Der perfekte Pfannkuchen - gelingt einfach immer', + 'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html', + 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', + 'recipe_yield': '4 servings', + 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Für alle Liebhaber von Dinkel ist dieses Dinkel-Sauerteigbrot ein absolutes Muss. Aussen knusprig und innen herrlich feucht und grossporig.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'yNDq', + 'name': 'Dinkel-Sauerteigbrot', + 'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/', + 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', + 'recipe_yield': '1', + 'slug': 'dinkel-sauerteigbrot', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 234234', + 'original_url': None, + 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', + 'recipe_yield': None, + 'slug': 'test-234234', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 243', + 'original_url': None, + 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', + 'recipe_yield': None, + 'slug': 'test-243', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nOPT', + 'name': 'Einfacher Nudelauflauf mit Brokkoli', + 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_yield': '4 servings', + 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': ''' + Tarta cytrynowa z bezą + Lekko kwaśna masa cytrynowa, która równoważy słodycz bezy – jeśli to brzmi jak ciasto, które chętnie zjesz na deser, wypróbuj nasz przepis! Tarta z bezą i masą cytrynową nawiązuje do kuchni francuskiej, znanej z wyśmienitych quiche i tart. Tym razem proponujemy ją w wersji na słodko. + Dla kogo? + Lubisz ciasta o delikatnym, kruchym spodzie? Posmakuje ci tarta cytrynowa z bezą. Przepis jest wprost stworzony dla miłośników lekko cierpkiego smaku cytrusów w wypiekach. Tarta cytrynowa z bezą zdecydowanie nie jest mdłym ciastem! + Na jaką okazję? + Na rodzinnym stole, zamiast zwykłego sernika lub ciasta czekoladowego, może stanąć właśnie tarta cytrynowa z bezą. Przepis ten skradnie serce twojej przyjaciółki lub przyjaciela, którego zaprosisz na herbatę i ciasto. Naszym zdaniem ma też dużą szansę stać się hitem urodzinowej imprezy, gdy pojawi się tuż obok tortu. Tarta cytrynowa z bezą smakuje doskonale w okresie świątecznym – upiecz ją na Wielkanoc oprócz tradycyjnego mazurka i baby. + Czy wiesz, że? + Zastanawiasz się, czy kupione kilka dni temu cytryny możesz przeznaczyć do przepisu na tartę? Jest wiele sposobów na przedłużenie ich świeżości. Niektórzy trzymają je w lodówce, w torebce zamykanej strunowo. Ciekawostka: im mocniej pachnie cytryna, tym kwaśniejsza będzie w smaku. + Dla urozmaicenia: + Martwisz się o to, czy każda warstwa tarty odpowiednio się upiecze? Mamy na to sposób. Piecz ją w piekarniku bez termoobiegu, ustawionym na grzanie góra–dół. + ''', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'vxuL', + 'name': 'Tarta cytrynowa z bezą', + 'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza', + 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', + 'recipe_yield': '8 servings', + 'slug': 'tarta-cytrynowa-z-beza', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Martins test Recipe', + 'original_url': None, + 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', + 'recipe_yield': None, + 'slug': 'martins-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Muffinki czekoladowe to przepyszny i bardzo prosty w przygotowaniu mini deser pieczony w papilotkach. Przepis na najlepsze, bardzo wilgotne i puszyste muffinki czekoladowe polecam każdemu miłośnikowi czekolady.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xP1Q', + 'name': 'Muffinki czekoladowe', + 'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe', + 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', + 'recipe_yield': '12', + 'slug': 'muffinki-czekoladowe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Recipe', + 'original_url': None, + 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', + 'recipe_yield': None, + 'slug': 'my-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Receipe', + 'original_url': None, + 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', + 'recipe_yield': None, + 'slug': 'my-test-receipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'r1ck', + 'name': 'Patates douces au four', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Follow these basic instructions for a thick, crisp, and chewy pizza crust at home. The recipe yields enough pizza dough for two 12-inch pizzas and you can freeze half of the dough for later. Close to 2 pounds of dough total.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'gD94', + 'name': 'Easy Homemade Pizza Dough', + 'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/', + 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', + 'recipe_yield': '2 servings', + 'slug': 'easy-homemade-pizza-dough', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '356X', + 'name': 'All-American Beef Stew Recipe', + 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_yield': '6 servings', + 'slug': 'all-american-beef-stew-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This utterly faithful recipe perfectly recreates a New York City halal-cart classic: Chicken and Rice with White Sauce. The chicken is marinated with herbs, lemon, and spices; the rice golden; the sauce, as white and creamy as ever.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '4Sys', + 'name': "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", + 'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe', + 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', + 'recipe_yield': '4 servings', + 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Schnelle Käsespätzle. Über 1201 Bewertungen und für sehr gut befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '8goY', + 'name': 'Schnelle Käsespätzle', + 'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html', + 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', + 'recipe_yield': '4 servings', + 'slug': 'schnelle-kasespatzle', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'taco', + 'original_url': None, + 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', + 'recipe_yield': None, + 'slug': 'taco', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'z8BB', + 'name': 'Vodkapasta', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'Nqpz', + 'name': 'Vodkapasta2', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Rub', + 'original_url': None, + 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', + 'recipe_yield': '1', + 'slug': 'rub', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Tender and moist, these chocolate chip cookies were a HUGE hit in the Test Kitchen. They're like banana bread in a cookie form. Outside, there are crisp edges like a cookie. Inside, though, it's soft like banana bread. We opted to add chocolate chips and nuts. It's a classic flavor combination in banana bread and works just as well in these cookies.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '03XS', + 'name': 'Banana Bread Chocolate Chip Cookies', + 'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html', + 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', + 'recipe_yield': '', + 'slug': 'banana-bread-chocolate-chip-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Hello friends, today I'm going to share with you how to make a delicious soup/bisque. A Cauliflower Bisques Recipe with Cheddar Cheese. One of my favorite soups to make when its cold outside. We will be continuing the soup collection so let me know what you think in the comments below!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KuXV', + 'name': 'Cauliflower Bisque Recipe with Cheddar Cheese', + 'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/', + 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', + 'recipe_yield': '', + 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Prova ', + 'original_url': None, + 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', + 'recipe_yield': '', + 'slug': 'prova', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre (1)', + 'original_url': None, + 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', + 'recipe_yield': None, + 'slug': 'pate-au-beurre-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre', + 'original_url': None, + 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', + 'recipe_yield': None, + 'slug': 'pate-au-beurre', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Individual foolproof mason jar cheesecakes with strawberry compote and a Graham cracker crumble topping. Foolproof, simple, and delicious.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tmwm', + 'name': 'Sous Vide Cheesecake Recipe', + 'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/', + 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', + 'recipe_yield': '4 servings', + 'slug': 'sous-vide-cheesecake-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This is a variation of the several cheese cake recipes that have been used for sous vide. These make a fabulous 4oz cheese cake for dessert. Garnish with a raspberry or blackberry and impress your family and friends. They’ll keep great in the fridge for a week easily.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xCYc', + 'name': 'The Bomb Mini Cheesecakes', + 'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes', + 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', + 'recipe_yield': '10 servings', + 'slug': 'the-bomb-mini-cheesecakes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tagliatelle al Salmone - wie beim Italiener. Über 1568 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'qzaN', + 'name': 'Tagliatelle al Salmone', + 'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html', + 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', + 'recipe_yield': '4 servings', + 'slug': 'tagliatelle-al-salmone', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Hier ist der Name Programm: Den "Tod durch Schokolade" müsst ihr zwar hoffentlich nicht erleiden, aber Chocoholics werden diesen Kuchen lieben!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'K9qP', + 'name': 'Death by Chocolate', + 'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html', + 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', + 'recipe_yield': '1 serving', + 'slug': 'death-by-chocolate', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Palak Dal ist in Grunde genommen Spinat (Palak) mit Linsen oder anderen Hülsenfrüchten (Dal) vom indischen Subkontinent. Es kommen noch Zwiebeln, Tomaten und einige indische Gewürze dazu. Damit ist das Palak Dal ein super einfaches und zugleich veganes indisches Rezept. Es schmeckt hervorragend mit Naan-Brot und etwas gewürztem Joghurt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'jKQ3', + 'name': 'Palak Dal Rezept aus Indien', + 'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/', + 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', + 'recipe_yield': '4 servings', + 'slug': 'palak-dal-rezept-aus-indien', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tortelline - á la Romana. Über 13 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rkSn', + 'name': 'Tortelline - á la Romana', + 'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html', + 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', + 'recipe_yield': '4 servings', + 'slug': 'tortelline-a-la-romana', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), + }), + }) +# --- # name: test_service_import_recipe dict({ 'recipe': dict({ @@ -9,7 +1247,7 @@ 'image': 'SuPW', 'ingredients': list([ dict({ - 'is_food': True, + 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', @@ -23,7 +1261,7 @@ 'unit': None, }), dict({ - 'is_food': True, + 'is_food': None, 'note': '150g softened butter', 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', @@ -525,7 +1763,7 @@ 'image': 'SuPW', 'ingredients': list([ dict({ - 'is_food': True, + 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', @@ -539,7 +1777,7 @@ 'unit': None, }), dict({ - 'is_food': True, + 'is_food': None, 'note': '150g softened butter', 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 57c55159bdc..8c5d073e3e9 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -14,13 +14,14 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, ATTR_NOTE_TEXT, ATTR_NOTE_TITLE, ATTR_RECIPE_ID, + ATTR_RESULT_LIMIT, + ATTR_SEARCH_TERMS, ATTR_START_DATE, ATTR_URL, DOMAIN, @@ -28,11 +29,12 @@ from homeassistant.components.mealie.const import ( from homeassistant.components.mealie.services import ( SERVICE_GET_MEALPLAN, SERVICE_GET_RECIPE, + SERVICE_GET_RECIPES, SERVICE_IMPORT_RECIPE, SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, ) -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -150,6 +152,42 @@ async def test_service_recipe( assert response == snapshot +@pytest.mark.parametrize( + "service_data", + [ + # Default call + {ATTR_CONFIG_ENTRY_ID: "mock_entry_id"}, + # With search terms and result limit + { + ATTR_CONFIG_ENTRY_ID: "mock_entry_id", + ATTR_SEARCH_TERMS: "pasta", + ATTR_RESULT_LIMIT: 5, + }, + ], +) +async def test_service_get_recipes( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + service_data: dict, +) -> None: + """Test the get_recipes service.""" + await setup_integration(hass, mock_config_entry) + + # Patch entry_id into service_data for each run + service_data = {**service_data, ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_RECIPES, + service_data, + blocking=True, + return_response=True, + ) + assert response == snapshot + + async def test_service_import_recipe( hass: HomeAssistant, mock_mealie_client: AsyncMock, @@ -332,6 +370,22 @@ async def test_service_set_mealplan( ServiceValidationError, "Recipe with ID or slug `recipe_id` not found", ), + ( + SERVICE_GET_RECIPES, + {}, + "get_recipes", + MealieConnectionError, + HomeAssistantError, + "Error connecting to Mealie instance", + ), + ( + SERVICE_GET_RECIPES, + {ATTR_SEARCH_TERMS: "pasta"}, + "get_recipes", + MealieNotFoundError, + ServiceValidationError, + "No recipes found matching your search", + ), ( SERVICE_IMPORT_RECIPE, {ATTR_URL: "http://example.com"}, @@ -402,6 +456,11 @@ async def test_services_connection_error( [ (SERVICE_GET_MEALPLAN, {}), (SERVICE_GET_RECIPE, {ATTR_RECIPE_ID: "recipe_id"}), + (SERVICE_GET_RECIPES, {}), + ( + SERVICE_GET_RECIPES, + {ATTR_SEARCH_TERMS: "pasta", ATTR_RESULT_LIMIT: 5}, + ), (SERVICE_IMPORT_RECIPE, {ATTR_URL: "http://example.com"}), ( SERVICE_SET_RANDOM_MEALPLAN, diff --git a/tests/components/mealie/test_todo.py b/tests/components/mealie/test_todo.py index d156ef3a0f1..0f001cacacd 100644 --- a/tests/components/mealie/test_todo.py +++ b/tests/components/mealie/test_todo.py @@ -221,8 +221,6 @@ async def test_moving_todo_item( display=None, checked=False, position=1, - is_food=False, - disable_amount=None, quantity=2.0, label_id=None, food_id=None, diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr index 582fd68efb1..654e631cdda 100644 --- a/tests/components/meater/snapshots/test_init.ambr +++ b/tests/components/meater/snapshots/test_init.ambr @@ -17,17 +17,15 @@ '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Apption Labs', 'model': 'Meater Probe', 'model_id': None, - 'name': 'Meater Probe 40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + 'name': 'Meater Probe 40a72384', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/meater/snapshots/test_sensor.ambr b/tests/components/meater/snapshots/test_sensor.ambr index aaec1db296a..f66bc854e2c 100644 --- a/tests/components/meater/snapshots/test_sensor.ambr +++ b/tests/components/meater/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-entry] +# name: test_entities[sensor.meater_probe_40a72384_ambient_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,8 +14,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', - 'has_entity_name': False, + 'entity_id': 'sensor.meater_probe_40a72384_ambient_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,7 +29,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, + 'original_name': 'Ambient temperature', 'platform': 'meater', 'previous_unique_id': None, 'suggested_object_id': None, @@ -39,124 +39,23 @@ 'unit_of_measurement': , }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-state] +# name: test_entities[sensor.meater_probe_40a72384_ambient_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Ambient temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'entity_id': 'sensor.meater_probe_40a72384_ambient_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '28.0', }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_name', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_name', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Whole chicken', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_peak_temp', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_peak_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-entry] +# name: test_entities[sensor.meater_probe_40a72384_cook_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -181,8 +80,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', - 'has_entity_name': False, + 'entity_id': 'sensor.meater_probe_40a72384_cook_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -193,7 +92,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, + 'original_name': 'Cook state', 'platform': 'meater', 'previous_unique_id': None, 'suggested_object_id': None, @@ -203,10 +102,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-state] +# name: test_entities[sensor.meater_probe_40a72384_cook_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', + 'friendly_name': 'Meater Probe 40a72384 Cook state', 'options': list([ 'not_started', 'configured', @@ -220,14 +120,62 @@ ]), }), 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'entity_id': 'sensor.meater_probe_40a72384_cook_state', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'started', }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-entry] +# name: test_entities[sensor.meater_probe_40a72384_cooking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_probe_40a72384_cooking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooking', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_name', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_cooking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Meater Probe 40a72384 Cooking', + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_cooking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Whole chicken', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_internal_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -242,8 +190,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', - 'has_entity_name': False, + 'entity_id': 'sensor.meater_probe_40a72384_internal_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -257,158 +205,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_target_temp', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_target_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.0', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_time_elapsed', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_elapsed', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-10-20T23:59:28+00:00', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_time_remaining', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_remaining', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-10-21T00:00:32+00:00', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, + 'original_name': 'Internal temperature', 'platform': 'meater', 'previous_unique_id': None, 'suggested_object_id': None, @@ -418,18 +215,229 @@ 'unit_of_measurement': , }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-state] +# name: test_entities[sensor.meater_probe_40a72384_internal_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Internal temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'entity_id': 'sensor.meater_probe_40a72384_internal_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '26.0', }) # --- +# name: test_entities[sensor.meater_probe_40a72384_peak_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_probe_40a72384_peak_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak temperature', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_peak_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_peak_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_peak_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Peak temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_peak_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.0', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_probe_40a72384_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_target_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_target_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_elapsed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_probe_40a72384_time_elapsed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time elapsed', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_elapsed', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_elapsed', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_elapsed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Meater Probe 40a72384 Time elapsed', + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_time_elapsed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-20T23:59:28+00:00', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_probe_40a72384_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time remaining', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_remaining', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Meater Probe 40a72384 Time remaining', + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:32+00:00', + }) +# --- diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py index c6704f2f3f7..9579ba3c1e9 100644 --- a/tests/components/meater/test_config_flow.py +++ b/tests/components/meater/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from meater import AuthenticationError, ServiceUnavailableError import pytest -from homeassistant.components.meater import DOMAIN +from homeassistant.components.meater.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py index 52f6b29d488..8f4e4e75a86 100644 --- a/tests/components/meater/test_init.py +++ b/tests/components/meater/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock from syrupy.assertion import SnapshotAssertion from homeassistant.components.meater.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration from .const import PROBE_ID @@ -26,3 +28,43 @@ async def test_device_info( device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) assert device_entry is not None assert device_entry == snapshot + + +async def test_load_unload( + hass: HomeAssistant, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test unload of Meater integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index ae3a84e66a0..e82f1cd3612 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -161,7 +161,12 @@ async def test_get_trigger_capabilities( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } @@ -193,7 +198,12 @@ async def test_get_trigger_capabilities_legacy( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index d1dc03ed12a..2b585319826 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -1,5 +1,8 @@ """The tests for the media_player platform.""" +import math +from unittest.mock import patch + import pytest from homeassistant.components.media_player import ( @@ -13,12 +16,17 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_SET, BrowseMedia, MediaClass, + MediaPlayerEntity, MediaType, SearchMedia, intent as media_player_intent, ) -from homeassistant.components.media_player.const import MediaPlayerEntityFeature +from homeassistant.components.media_player.const import ( + MediaPlayerEntityFeature, + MediaPlayerState, +) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, STATE_IDLE, STATE_PAUSED, @@ -32,8 +40,10 @@ from homeassistant.helpers import ( floor_registry as fr, intent, ) +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service async def test_pause_media_player_intent(hass: HomeAssistant) -> None: @@ -873,3 +883,165 @@ async def test_search_and_play_media_player_intent_with_media_class( "media_class": {"value": "invalid_class"}, }, ) + + +@pytest.mark.parametrize( + ("direction", "volume_change", "volume_change_int"), + [("up", 0.1, 20), ("down", -0.1, -20)], +) +async def test_volume_relative_media_player_intent( + hass: HomeAssistant, direction: str, volume_change: float, volume_change_int: int +) -> None: + """Test relative volume intents for media players.""" + assert await async_setup_component(hass, DOMAIN, {}) + await media_player_intent.async_setup_intents(hass) + + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + default_volume = 0.5 + + class VolumeTestMediaPlayer(MediaPlayerEntity): + _attr_supported_features = MediaPlayerEntityFeature.VOLUME_SET + _attr_volume_level = default_volume + _attr_volume_step = 0.1 + _attr_state = MediaPlayerState.IDLE + + async def async_set_volume_level(self, volume): + self._attr_volume_level = volume + + idle_entity = VolumeTestMediaPlayer() + idle_entity.hass = hass + idle_entity.platform = MockEntityPlatform(hass) + idle_entity.entity_id = f"{DOMAIN}.idle_media_player" + await component.async_add_entities([idle_entity]) + + hass.states.async_set( + idle_entity.entity_id, + STATE_IDLE, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Idle Media Player", + }, + ) + + idle_expected_volume = default_volume + + # Only 1 media player is present, so it's targeted even though its idle + assert idle_entity.volume_level is not None + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + + # Multiple media players (playing one should be targeted) + playing_entity = VolumeTestMediaPlayer() + playing_entity.hass = hass + playing_entity.platform = MockEntityPlatform(hass) + playing_entity.entity_id = f"{DOMAIN}.playing_media_player" + await component.async_add_entities([playing_entity]) + + hass.states.async_set( + playing_entity.entity_id, + STATE_PLAYING, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Playing Media Player", + }, + ) + + playing_expected_volume = default_volume + assert playing_entity.volume_level is not None + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # We can still target by name even if the media player is idle + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}, "name": {"value": "Idle media player"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Set relative volume by percent + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": volume_change_int}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change_int / 100 + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Test error in method + with ( + patch.object( + playing_entity, "async_volume_up", side_effect=RuntimeError("boom!") + ), + pytest.raises(intent.IntentError), + ): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": "up"}}, + ) + + # Multiple idle media players should not match + hass.states.async_set( + playing_entity.entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + + # Test feature not supported + for entity_id in (idle_entity.entity_id, playing_entity.entity_id): + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) diff --git a/tests/components/media_source/test_const.py b/tests/components/media_source/test_const.py new file mode 100644 index 00000000000..115c98a2c09 --- /dev/null +++ b/tests/components/media_source/test_const.py @@ -0,0 +1,80 @@ +"""Test constants for the media source component.""" + +import pytest + +from homeassistant.components.media_source.const import URI_SCHEME_REGEX + + +@pytest.mark.parametrize( + ("uri", "expected_domain", "expected_identifier"), + [ + ("media-source://", None, None), + ("media-source://local_media", "local_media", None), + ( + "media-source://local_media/some/path/file.mp3", + "local_media", + "some/path/file.mp3", + ), + ("media-source://a/b", "a", "b"), + ( + "media-source://domain/file with spaces.mp4", + "domain", + "file with spaces.mp4", + ), + ( + "media-source://domain/file-with-dashes.mp3", + "domain", + "file-with-dashes.mp3", + ), + ("media-source://domain/file.with.dots.mp3", "domain", "file.with.dots.mp3"), + ( + "media-source://domain/special!@#$%^&*()chars", + "domain", + "special!@#$%^&*()chars", + ), + ], +) +def test_valid_uri_patterns( + uri: str, expected_domain: str | None, expected_identifier: str | None +) -> None: + """Test various valid URI patterns.""" + match = URI_SCHEME_REGEX.match(uri) + assert match is not None + assert match.group("domain") == expected_domain + assert match.group("identifier") == expected_identifier + + +@pytest.mark.parametrize( + "uri", + [ + "media-source:", # missing // + "media-source:/", # missing second / + "media-source:///", # extra / + "media-source://domain/", # trailing slash after domain + "invalid-scheme://domain", # wrong scheme + "media-source//domain", # missing : + "MEDIA-SOURCE://domain", # uppercase scheme + "media_source://domain", # underscore in scheme + "", # empty string + "media-source", # scheme only + "media-source://domain extra", # extra content + "prefix media-source://domain", # prefix content + "media-source://domain suffix", # suffix content + # Invalid domain names + "media-source://_test", # starts with underscore + "media-source://test_", # ends with underscore + "media-source://_test_", # starts and ends with underscore + "media-source://_", # single underscore + "media-source://test-123", # contains hyphen + "media-source://test.123", # contains dot + "media-source://test 123", # contains space + "media-source://TEST", # uppercase letters + "media-source://Test", # mixed case + # Identifier cannot start with slash + "media-source://domain//invalid", # identifier starts with slash + ], +) +def test_invalid_uris(uri: str) -> None: + """Test invalid URI formats.""" + match = URI_SCHEME_REGEX.match(uri) + assert match is None, f"URI '{uri}' should be invalid" diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index d3ae95736a5..259407bfb5a 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -105,6 +105,9 @@ async def test_media_view( client = await hass_client() # Protects against non-existent files + resp = await client.head("/media/local/invalid.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/invalid.txt") assert resp.status == HTTPStatus.NOT_FOUND @@ -112,14 +115,23 @@ async def test_media_view( assert resp.status == HTTPStatus.NOT_FOUND # Protects against non-media files + resp = await client.head("/media/local/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Protects against unknown local media sources + resp = await client.head("/media/unknown_source/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/unknown_source/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Fetch available media + resp = await client.head("/media/local/test.mp3") + assert resp.status == HTTPStatus.OK + resp = await client.get("/media/local/test.mp3") assert resp.status == HTTPStatus.OK @@ -155,13 +167,23 @@ async def test_upload_view( res = await client.post( "/api/media_source/local_source/upload", data={ - "media_content_id": "media-source://media_source/test_dir/.", + "media_content_id": "media-source://media_source/test_dir", "file": get_file("logo.png"), }, ) assert res.status == 200 - assert (Path(temp_dir) / "logo.png").is_file() + data = await res.json() + assert data["media_content_id"] == "media-source://media_source/test_dir/logo.png" + uploaded_path = Path(temp_dir) / "logo.png" + assert uploaded_path.is_file() + + resolved = await media_source.async_resolve_media( + hass, data["media_content_id"], target_media_player=None + ) + assert resolved.url == "/media/test_dir/logo.png" + assert resolved.mime_type == "image/png" + assert resolved.path == uploaded_path # Test with bad media source ID for bad_id in ( diff --git a/tests/components/media_source/test_models.py b/tests/components/media_source/test_models.py index 12685e28d69..1ed03a83961 100644 --- a/tests/components/media_source/test_models.py +++ b/tests/components/media_source/test_models.py @@ -2,6 +2,7 @@ from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source import const, models +from homeassistant.core import HomeAssistant async def test_browse_media_as_dict() -> None: @@ -68,3 +69,18 @@ async def test_media_source_default_name() -> None: """Test MediaSource uses domain as default name.""" source = models.MediaSource(const.DOMAIN) assert source.name == const.DOMAIN + + +async def test_media_source_item_media_source_id(hass: HomeAssistant) -> None: + """Test MediaSourceItem media_source_id property.""" + # Test with domain and identifier + item = models.MediaSourceItem(hass, "test_domain", "test/identifier", None) + assert item.media_source_id == "media-source://test_domain/test/identifier" + + # Test with domain only + item = models.MediaSourceItem(hass, "test_domain", "", None) + assert item.media_source_id == "media-source://test_domain" + + # Test with no domain (root) + item = models.MediaSourceItem(hass, None, "", None) + assert item.media_source_id == "media-source://" diff --git a/tests/components/met_eireann/__init__.py b/tests/components/met_eireann/__init__.py index c38f197691a..a65ba64accd 100644 --- a/tests/components/met_eireann/__init__.py +++ b/tests/components/met_eireann/__init__.py @@ -19,7 +19,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: } entry = MockConfigEntry(domain=DOMAIN, data=entry_data) with patch( - "homeassistant.components.met_eireann.meteireann.WeatherData.fetching_data", + "homeassistant.components.met_eireann.coordinator.meteireann.WeatherData.fetching_data", return_value=True, ): entry.add_to_hass(hass) diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index 1e385c9a600..54931dd4c12 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -6,8 +6,8 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.met_eireann import UPDATE_INTERVAL from homeassistant.components.met_eireann.const import DOMAIN +from homeassistant.components.met_eireann.coordinator import UPDATE_INTERVAL from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, diff --git a/tests/components/meteoclimatic/conftest.py b/tests/components/meteoclimatic/conftest.py index a481b811a77..8bd600a4f6f 100644 --- a/tests/components/meteoclimatic/conftest.py +++ b/tests/components/meteoclimatic/conftest.py @@ -8,7 +8,9 @@ import pytest @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteoclimatic.MeteoclimaticClient") + patch_client = patch( + "homeassistant.components.meteoclimatic.coordinator.MeteoclimaticClient" + ) with patch_client: yield diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py index 59061f12ddc..436bc636899 100644 --- a/tests/components/metoffice/const.py +++ b/tests/components/metoffice/const.py @@ -40,6 +40,12 @@ KINGSLYNN_SENSOR_RESULTS = { "probability_of_precipitation": "67", "pressure": "998.20", "wind_speed": "22.21", + "wind_direction": "180", + "wind_gust": "40.26", + "feels_like_temperature": "3.4", + "visibility_distance": "7478.00", + "humidity": "97.5", + "station_name": "King's Lynn", } WAVERTREE_SENSOR_RESULTS = { @@ -49,6 +55,12 @@ WAVERTREE_SENSOR_RESULTS = { "probability_of_precipitation": "61", "pressure": "987.50", "wind_speed": "17.60", + "wind_direction": "176", + "wind_gust": "34.52", + "feels_like_temperature": "5.8", + "visibility_distance": "5106.00", + "humidity": "95.13", + "station_name": "Wavertree", } DEVICE_KEY_KINGSLYNN = {(DOMAIN, TEST_COORDINATES_KINGSLYNN)} diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index bd139873073..5ce069a3d09 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -28,6 +28,7 @@ from tests.common import MockConfigEntry, async_load_fixture, get_sensor_display @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_one_sensor_site_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -78,6 +79,7 @@ async def test_one_sensor_site_running( @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_two_sensor_sites_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 94112e29143..c8a47eb2b59 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -20,8 +20,8 @@ from .const import CLIENT_ID, CLIENT_SECRET from tests.common import ( MockConfigEntry, - async_load_fixture, async_load_json_object_fixture, + load_json_value_fixture, ) @@ -99,13 +99,13 @@ async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAct @pytest.fixture(scope="package") def load_programs_file() -> str: """Fixture for loading programs file.""" - return "programs_washing_machine.json" + return "programs.json" @pytest.fixture async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]: """Fixture for available programs.""" - return await async_load_fixture(hass, load_programs_file, DOMAIN) + return load_json_value_fixture(load_programs_file, DOMAIN) @pytest.fixture @@ -117,7 +117,7 @@ def mock_miele_client( """Mock a Miele client.""" with patch( - "homeassistant.components.miele.AsyncConfigEntryAuth", + "homeassistant.components.miele.MieleAPI", autospec=True, ) as mock_client: client = mock_client.return_value @@ -125,6 +125,7 @@ def mock_miele_client( client.get_devices.return_value = device_fixture client.get_actions.return_value = action_fixture client.get_programs.return_value = programs_fixture + client.set_program.return_value = None yield client diff --git a/tests/components/miele/fixtures/4_actions.json b/tests/components/miele/fixtures/4_actions.json index 6a89fb4604a..903a075df3c 100644 --- a/tests/components/miele/fixtures/4_actions.json +++ b/tests/components/miele/fixtures/4_actions.json @@ -82,5 +82,20 @@ "colors": [], "modes": [], "runOnTime": [] + }, + "DummyAppliance_12": { + "processAction": [], + "light": [2], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [], + "deviceName": true, + "powerOn": false, + "powerOff": true, + "colors": [], + "modes": [], + "runOnTime": [] } } diff --git a/tests/components/miele/fixtures/4_devices.json b/tests/components/miele/fixtures/4_devices.json index b63c60ff4d3..7d6ee9a7173 100644 --- a/tests/components/miele/fixtures/4_devices.json +++ b/tests/components/miele/fixtures/4_devices.json @@ -466,5 +466,129 @@ "ecoFeedback": null, "batteryLevel": null } + }, + "DummyAppliance_12": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 356, + "value_localized": "Defrost", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 3073, + "value_localized": "Heating-up phase", + "key_localized": "Program phase" + }, + "remainingTime": [0, 5], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 2500, + "value_localized": 25.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": 1954, + "value_localized": 19.54, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": 2200, + "value_localized": 22.0, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": true + }, + "ambientLight": null, + "light": 1, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } } } diff --git a/tests/components/miele/fixtures/5_devices.json b/tests/components/miele/fixtures/5_devices.json index 113babbd3f7..2e76c1f6ef5 100644 --- a/tests/components/miele/fixtures/5_devices.json +++ b/tests/components/miele/fixtures/5_devices.json @@ -648,5 +648,129 @@ }, "batteryLevel": null } + }, + "DummyAppliance_12": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 356, + "value_localized": "Defrost", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 3073, + "value_localized": "Heating-up phase", + "key_localized": "Program phase" + }, + "remainingTime": [0, 5], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 2500, + "value_localized": 25.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": 1954, + "value_localized": 19.54, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": 2200, + "value_localized": 22.0, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": true + }, + "ambientLight": null, + "light": 1, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } } } diff --git a/tests/components/miele/fixtures/action_fridge_freezer.json b/tests/components/miele/fixtures/action_fridge_freezer.json new file mode 100644 index 00000000000..94ee43a90fe --- /dev/null +++ b/tests/components/miele/fixtures/action_fridge_freezer.json @@ -0,0 +1,31 @@ +{ + "processAction": [6], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + }, + { + "zone": 2, + "min": -28, + "max": -14 + }, + { + "zone": 3, + "min": -30, + "max": -15 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/fridge_freezer.json b/tests/components/miele/fixtures/fridge_freezer.json new file mode 100644 index 00000000000..8ca28befc35 --- /dev/null +++ b/tests/components/miele/fixtures/fridge_freezer.json @@ -0,0 +1,114 @@ +{ + "DummyAppliance_Fridge_Freezer": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 21, + "value_localized": "Fridge freezer" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KFN 7734 C", + "matNumber": "12336150", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK037LHBM", + "releaseVersion": "32.33" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + }, + { + "value_raw": -2500, + "value_localized": -25.0, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + }, + { + "value_raw": -2800, + "value_localized": -28.0, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/oven.json b/tests/components/miele/fixtures/oven.json new file mode 100644 index 00000000000..dbf14d4546c --- /dev/null +++ b/tests/components/miele/fixtures/oven.json @@ -0,0 +1,142 @@ +{ + "DummyOven": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [ + "6166", + "25211", + "25210", + "4860", + "25245", + "6153", + "6050", + "25300", + "25307", + "25247", + "20570", + "25223", + "5640", + "20366", + "20462" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/programs.json b/tests/components/miele/fixtures/programs.json new file mode 100644 index 00000000000..1c232059d59 --- /dev/null +++ b/tests/components/miele/fixtures/programs.json @@ -0,0 +1,38 @@ +[ + { + "programId": 1, + "program": "Cottons", + "parameters": {} + }, + { + "programId": 146, + "program": "QuickPowerWash", + "parameters": {} + }, + { + "programId": 123, + "program": "Dark garments / Denim ", + "parameters": {} + }, + { + "programId": 13, + "program": "Fan plus", + "parameters": { + "temperature": { + "min": 30, + "max": 250, + "step": 5, + "mandatory": false + }, + "duration": { + "min": [0, 1], + "max": [12, 0], + "mandatory": true + } + } + }, + { + "programId": 24000, + "program": "Ristretto" + } +] diff --git a/tests/components/miele/fixtures/programs_washing_machine.json b/tests/components/miele/fixtures/programs_washing_machine.json deleted file mode 100644 index a3c16ece8e6..00000000000 --- a/tests/components/miele/fixtures/programs_washing_machine.json +++ /dev/null @@ -1,117 +0,0 @@ -[ - { - "programId": 146, - "program": "QuickPowerWash", - "parameters": {} - }, - { - "programId": 123, - "program": "Dark garments / Denim", - "parameters": {} - }, - { - "programId": 190, - "program": "ECO 40-60 ", - "parameters": {} - }, - { - "programId": 27, - "program": "Proofing", - "parameters": {} - }, - { - "programId": 23, - "program": "Shirts", - "parameters": {} - }, - { - "programId": 9, - "program": "Silks ", - "parameters": {} - }, - { - "programId": 8, - "program": "Woollens ", - "parameters": {} - }, - { - "programId": 4, - "program": "Delicates", - "parameters": {} - }, - { - "programId": 3, - "program": "Minimum iron", - "parameters": {} - }, - { - "programId": 1, - "program": "Cottons", - "parameters": {} - }, - { - "programId": 69, - "program": "Cottons hygiene", - "parameters": {} - }, - { - "programId": 37, - "program": "Outerwear", - "parameters": {} - }, - { - "programId": 122, - "program": "Express 20", - "parameters": {} - }, - { - "programId": 29, - "program": "Sportswear", - "parameters": {} - }, - { - "programId": 31, - "program": "Automatic plus", - "parameters": {} - }, - { - "programId": 39, - "program": "Pillows", - "parameters": {} - }, - { - "programId": 22, - "program": "Curtains", - "parameters": {} - }, - { - "programId": 129, - "program": "Down filled items", - "parameters": {} - }, - { - "programId": 53, - "program": "First wash", - "parameters": {} - }, - { - "programId": 95, - "program": "Down duvets", - "parameters": {} - }, - { - "programId": 52, - "program": "Separate rinse / Starch", - "parameters": {} - }, - { - "programId": 21, - "program": "Drain / Spin", - "parameters": {} - }, - { - "programId": 91, - "program": "Clean machine", - "parameters": {} - } -] diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr index f102c925c98..9a3de2ddd49 100644 --- a/tests/components/miele/snapshots/test_binary_sensor.ambr +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -532,6 +532,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1647,6 +1938,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states_api_push[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_button.ambr b/tests/components/miele/snapshots/test_button.ambr index 6e6f3cbb72d..e4eb80587c9 100644 --- a/tests/components/miele/snapshots/test_button.ambr +++ b/tests/components/miele/snapshots/test_button.ambr @@ -47,6 +47,102 @@ 'state': 'unknown', }) # --- +# name: test_button_states[platforms0][button.oven_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_states[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -239,6 +335,102 @@ 'state': 'unavailable', }) # --- +# name: test_button_states_api_push[platforms0][button.oven_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_button_states_api_push[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_climate.ambr b/tests/components/miele/snapshots/test_climate.ambr index 0fb24c893c4..3b8b7488d9b 100644 --- a/tests/components/miele/snapshots/test_climate.ambr +++ b/tests/components/miele/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_climate_states[platforms0-freezer][climate.freezer-entry] +# name: test_climate_states[freezer-platforms0][climate.freezer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -41,7 +41,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states[platforms0-freezer][climate.freezer-state] +# name: test_climate_states[freezer-platforms0][climate.freezer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': -18, @@ -63,7 +63,7 @@ 'state': 'cool', }) # --- -# name: test_climate_states[platforms0-freezer][climate.refrigerator-entry] +# name: test_climate_states[freezer-platforms0][climate.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -105,7 +105,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states[platforms0-freezer][climate.refrigerator-state] +# name: test_climate_states[freezer-platforms0][climate.refrigerator-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 4, @@ -127,7 +127,7 @@ 'state': 'cool', }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.freezer-entry] +# name: test_climate_states_api_push[freezer-platforms0][climate.freezer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -169,7 +169,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.freezer-state] +# name: test_climate_states_api_push[freezer-platforms0][climate.freezer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': -18, @@ -191,7 +191,7 @@ 'state': 'cool', }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.refrigerator-entry] +# name: test_climate_states_api_push[freezer-platforms0][climate.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -233,7 +233,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.refrigerator-state] +# name: test_climate_states_api_push[freezer-platforms0][climate.refrigerator-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 4, @@ -255,3 +255,195 @@ 'state': 'cool', }) # --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Freezer', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat2-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -18, + 'friendly_name': 'Fridge freezer Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -18, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_refrigerator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Refrigerator', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4, + 'friendly_name': 'Fridge freezer Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -15, + 'min_temp': -30, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_zone_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'zone_3', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat3-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -28, + 'friendly_name': 'Fridge freezer Zone 3', + 'hvac_modes': list([ + , + ]), + 'max_temp': -15, + 'min_temp': -30, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -25, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr index 8fa40755888..54f6083a74c 100644 --- a/tests/components/miele/snapshots/test_diagnostics.ambr +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -144,6 +144,39 @@ 'ventilationStep': list([ ]), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), }), 'devices': dict({ '**REDACTED_019aa577ad1c330d': dict({ @@ -661,6 +694,141 @@ }), }), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '16', + 'fabNumber': '**REDACTED**', + 'matNumber': '11120960', + 'swids': list([ + ]), + 'techType': 'H7660BP', + }), + 'deviceName': '', + 'protocolVersion': 4, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Oven', + 'value_raw': 12, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '08.32', + 'techType': 'EK057', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': 'Defrost', + 'value_raw': 356, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'coreTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 22.0, + 'value_raw': 2200, + }), + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + 0, + 0, + ]), + 'light': 1, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': 'Heating-up phase', + 'value_raw': 3073, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': 'Program', + 'value_raw': 1, + }), + 'remainingTime': list([ + 0, + 5, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': True, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 25.0, + 'value_raw': 2500, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 19.54, + 'value_raw': 1954, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), }), 'missing_code_warnings': list([ 'None', diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr index eee976ab09f..81f6c0c3a35 100644 --- a/tests/components/miele/snapshots/test_init.ambr +++ b/tests/components/miele/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'Dummy_Appliance_1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Miele', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'Dummy_Appliance_1', - 'suggested_area': None, 'sw_version': '31.17', 'via_device_id': None, }) diff --git a/tests/components/miele/snapshots/test_light.ambr b/tests/components/miele/snapshots/test_light.ambr index 8c4a4f4bff9..243536fc997 100644 --- a/tests/components/miele/snapshots/test_light.ambr +++ b/tests/components/miele/snapshots/test_light.ambr @@ -113,6 +113,63 @@ 'state': 'on', }) # --- +# name: test_light_states[platforms0][light.oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_light_states_api_push[platforms0][light.hood_ambient_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -227,3 +284,60 @@ 'state': 'on', }) # --- +# name: test_light_states_api_push[platforms0][light.oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states_api_push[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index b1691c28b19..5d941550f41 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1,4 +1,1362 @@ # serializer version: 1 +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pot-steam-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_74-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction', + 'icon': 'mdi:pot-steam-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pot-steam-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_74_off-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction', + 'icon': 'mdi:pot-steam-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 1', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 1', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 1', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 1', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 2', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_3', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_2_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 2', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_2_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 3', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_7', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_3_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 3', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 4', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 4', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_15', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_4_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 4', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 4', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_4_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 5', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 5', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_boost_2', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_boost', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:turbine', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_18-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hood', + 'icon': 'mdi:turbine', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Fridge freezer', + 'icon': 'mdi:fridge-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer_temperature_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature zone 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_zone_2', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature zone 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -103,6 +1461,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -116,6 +1475,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -160,6 +1520,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -173,6 +1534,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -197,6 +1559,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -210,6 +1573,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -254,6 +1618,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -267,6 +1632,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -291,6 +1657,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -304,6 +1671,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -348,6 +1716,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -361,6 +1730,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -385,6 +1755,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -398,6 +1769,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -442,6 +1814,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -455,6 +1828,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -479,6 +1853,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -492,6 +1867,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -536,6 +1912,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -549,6 +1926,7 @@ 'plate_step_8', 'plate_step_9', 'plate_step_boost', + 'plate_step_boost_2', 'plate_step_warming', ]), }), @@ -798,6 +2176,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_core_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_start_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1630,6 +3923,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1973,6 +4322,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_core_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_start_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2805,6 +6069,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2910,3 +6230,378 @@ 'state': '0.0', }) # --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:robot-vacuum', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Vacuum_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner', + 'icon': 'mdi:robot-vacuum', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Vacuum_1-state_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum cleaner Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'Dummy_Vacuum_1-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'Dummy_Vacuum_1-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program', + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'Dummy_Vacuum_1-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal_operation_mode', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'Dummy_Vacuum_1-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/miele/snapshots/test_services.ambr b/tests/components/miele/snapshots/test_services.ambr new file mode 100644 index 00000000000..3c3feca7832 --- /dev/null +++ b/tests/components/miele/snapshots/test_services.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_services_with_response + dict({ + 'programs': list([ + dict({ + 'parameters': dict({ + }), + 'program': 'Cottons', + 'program_id': 1, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'QuickPowerWash', + 'program_id': 146, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'Dark garments / Denim', + 'program_id': 123, + }), + dict({ + 'parameters': dict({ + 'duration': dict({ + 'mandatory': True, + 'max': dict({ + 'hours': 12, + 'minutes': 0, + }), + 'min': dict({ + 'hours': 0, + 'minutes': 1, + }), + }), + 'temperature': dict({ + 'mandatory': False, + 'max': 250, + 'min': 30, + 'step': 5, + }), + }), + 'program': 'Fan plus', + 'program_id': 13, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'Ristretto', + 'program_id': 24000, + }), + ]), + }) +# --- diff --git a/tests/components/miele/snapshots/test_switch.ambr b/tests/components/miele/snapshots/test_switch.ambr index c8ca88c5b59..769b08271a5 100644 --- a/tests/components/miele/snapshots/test_switch.ambr +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -95,6 +95,54 @@ 'state': 'off', }) # --- +# name: test_switch_states[platforms0][switch.oven_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.oven_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -287,6 +335,54 @@ 'state': 'off', }) # --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.oven_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_states_api_push[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_vacuum.ambr b/tests/components/miele/snapshots/test_vacuum.ambr index 9f96db7b05a..3b808ad9cd2 100644 --- a/tests/components/miele/snapshots/test_vacuum.ambr +++ b/tests/components/miele/snapshots/test_vacuum.ambr @@ -34,7 +34,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -43,8 +43,6 @@ # name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -52,7 +50,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', @@ -97,7 +95,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -106,8 +104,6 @@ # name: test_vacuum_states_api_push[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -115,7 +111,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', diff --git a/tests/components/miele/test_climate.py b/tests/components/miele/test_climate.py index c4966430a9d..392a6712707 100644 --- a/tests/components/miele/test_climate.py +++ b/tests/components/miele/test_climate.py @@ -15,21 +15,13 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform TEST_PLATFORM = CLIMATE_DOMAIN -pytestmark = [ - pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]), - pytest.mark.parametrize( - "load_action_file", - ["action_freezer.json"], - ids=[ - "freezer", - ], - ), -] +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) ENTITY_ID = "climate.freezer" SERVICE_SET_TEMPERATURE = "set_temperature" +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_climate_states( hass: HomeAssistant, mock_miele_client: MagicMock, @@ -42,7 +34,24 @@ async def test_climate_states( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) +@pytest.mark.parametrize( + "load_action_file", ["action_fridge_freezer.json"], ids=["fridge_freezer"] +) +async def test_climate_states_mulizone( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test climate entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_climate_states_api_push( hass: HomeAssistant, mock_miele_client: MagicMock, @@ -56,6 +65,7 @@ async def test_climate_states_api_push( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_set_target( hass: HomeAssistant, mock_miele_client: MagicMock, @@ -74,6 +84,7 @@ async def test_set_target( ) +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_api_failure( hass: HomeAssistant, mock_miele_client: MagicMock, diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py index bbe5844c1cd..5ce129b255d 100644 --- a/tests/components/miele/test_config_flow.py +++ b/tests/components/miele/test_config_flow.py @@ -46,7 +46,6 @@ async def test_full_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -118,7 +117,6 @@ async def test_flow_reauth_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -187,7 +185,6 @@ async def test_flow_reconfigure_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -247,7 +244,6 @@ async def test_zeroconf_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index dd3f3b95d02..cdf1a39b421 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -109,7 +109,7 @@ async def test_devices_multiple_created_count( """Test that multiple devices are created.""" await setup_integration(hass, mock_config_entry) - assert len(device_registry.devices) == 4 + assert len(device_registry.devices) == 5 async def test_device_info( @@ -200,11 +200,13 @@ async def test_setup_all_platforms( ) freezer.tick(timedelta(seconds=130)) + prev_devices = len(device_registry.devices) + async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(device_registry.devices) == 6 + assert len(device_registry.devices) == prev_devices + 2 # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" - assert hass.states.get("sensor.oven_temperature").state == "175.0" + assert hass.states.get("sensor.oven_temperature_2").state == "175.0" diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 47e101c6636..e5051a683c9 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -1,15 +1,24 @@ """Tests for miele sensor module.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pymiele import MieleDevices import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.miele.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @@ -54,3 +63,211 @@ async def test_hob_sensor_states( """Test sensor state.""" await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fridge_freezer_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_temperatures_scenario( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + mock_config_entry: MockConfigEntry, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Parametrized test for verifying temperature sensors for oven devices.""" + + # Initial state when the oven is and created for the first time - don't know if it supports core temperature (probe) + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 0) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 0) + + # Simulate temperature settings, no probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2150 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.5 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.5", 1) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 1) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 1) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 1) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 2) + + # Simulate temperature settings with probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0]["value_raw"] = 3000 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = 30.0 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2183 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.83 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.83", 3) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 3) + check_sensor_state(hass, "sensor.oven_core_temperature", "22.0", 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "30.0", 3) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_raw" + ] = -32768 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = None + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "unknown", 4) + + +def check_sensor_state( + hass: HomeAssistant, + sensor_entity: str, + expected: str, + step: int, +): + """Check the state of sensor matches the expected state.""" + + state = hass.states.get(sensor_entity) + + if expected is None: + assert state is None, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected nothing" + ) + else: + assert state is not None, f"Missing entity: {sensor_entity}" + assert state.state == expected, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected {expected}" + ) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_temperature_sensor_registry_lookup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that core temperature sensor is provided by the integration after looking up in entity registry.""" + + # Initial state, the oven is showing core temperature (probe) + freezer.tick(timedelta(seconds=130)) + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "sensor.oven_core_temperature" + + assert hass.states.get(entity_id) is not None + assert hass.states.get(entity_id).state == "22.0" + + # reload device when turned off, reporting the invalid value + mock_miele_client.get_devices.return_value = await async_load_json_object_fixture( + hass, "oven.json", DOMAIN + ) + + # unload config entry and reload to make sure that the entity is still provided + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unavailable" + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unknown" + + +@pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_vacuum_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test robot vacuum cleaner sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fan_hob_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test robot fan / hob sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py new file mode 100644 index 00000000000..38b9f064b55 --- /dev/null +++ b/tests/components/miele/test_services.py @@ -0,0 +1,216 @@ +"""Tests the services provided by the miele integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from syrupy.assertion import SnapshotAssertion +from voluptuous import MultipleInvalid + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.components.miele.services import ( + ATTR_DURATION, + ATTR_PROGRAM_ID, + SERVICE_GET_PROGRAMS, + SERVICE_SET_PROGRAM, + SERVICE_SET_PROGRAM_OVEN, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_APPLIANCE = "Dummy_Appliance_1" + + +async def test_services( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + { + ATTR_DEVICE_ID: device.id, + ATTR_PROGRAM_ID: 24, + }, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 24} + ) + + +@pytest.mark.parametrize( + ("call_arguments", "miele_arguments"), + [ + ( + {ATTR_PROGRAM_ID: 24}, + {"programId": 24}, + ), + ( + {ATTR_PROGRAM_ID: 25, ATTR_DURATION: timedelta(minutes=75)}, + {"programId": 25, "duration": [1, 15]}, + ), + ( + { + ATTR_PROGRAM_ID: 26, + ATTR_DURATION: timedelta(minutes=135), + ATTR_TEMPERATURE: 180, + }, + {"programId": 26, "duration": [2, 15], "temperature": 180}, + ), + ], +) +async def test_services_oven( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + call_arguments: dict, + miele_arguments: dict, +) -> None: + """Tests that the custom services are correct for ovens.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM_OVEN, + {ATTR_DEVICE_ID: device.id, **call_arguments}, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, miele_arguments + ) + + +async def test_services_with_response( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the custom services that returns a response are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + { + ATTR_DEVICE_ID: device.id, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("service", "error"), + [ + (SERVICE_SET_PROGRAM, "'Set program' action failed"), + (SERVICE_SET_PROGRAM_OVEN, "'Set program on oven' action failed"), + ], +) +async def test_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + service: str, + error: str, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.set_program.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 1} + ) + + +async def test_get_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.get_programs.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match="'Get programs' action failed"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + {ATTR_DEVICE_ID: device.id}, + blocking=True, + return_response=True, + ) + mock_miele_client.get_programs.assert_called_once() + + +async def test_service_validation_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services handle bad data.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test missing program_id + with pytest.raises(MultipleInvalid, match="required key not provided"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid program_id + with pytest.raises(MultipleInvalid, match="expected int for dictionary value"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id, ATTR_PROGRAM_ID: "invalid"}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid device + with pytest.raises(ServiceValidationError, match="Invalid device targeted"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": "invalid_device", ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() diff --git a/tests/components/mill/test_coordinator.py b/tests/components/mill/test_coordinator.py index a2a3bd57b65..2e6e08016b7 100644 --- a/tests/components/mill/test_coordinator.py +++ b/tests/components/mill/test_coordinator.py @@ -11,12 +11,15 @@ from homeassistant.components.recorder.statistics import statistics_during_perio from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util +from tests.common import MockConfigEntry from tests.components.recorder.common import async_wait_recording_done async def test_mill_historic_data(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -31,7 +34,7 @@ async def test_mill_historic_data(recorder_mock: Recorder, hass: HomeAssistant) statistic_id = f"{DOMAIN}:energy_dev_id" coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -96,6 +99,8 @@ async def test_mill_historic_data_no_heater( ) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -110,7 +115,7 @@ async def test_mill_historic_data_no_heater( statistic_id = f"{DOMAIN}:energy_dev_id" coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -133,6 +138,8 @@ async def test_mill_historic_data_no_data( ) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -145,7 +152,7 @@ async def test_mill_historic_data_no_data( mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -168,7 +175,7 @@ async def test_mill_historic_data_no_data( mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=None) coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -192,6 +199,8 @@ async def test_mill_historic_data_invalid_data( ) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): None, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -206,7 +215,7 @@ async def test_mill_historic_data_invalid_data( statistic_id = f"{DOMAIN}:energy_dev_id" coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index a35cc95605d..f7bd4b13a1b 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -23,6 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from tests.common import async_fire_time_changed, mock_restore_cache @@ -121,6 +122,7 @@ def mock_pymodbus_fixture(do_exception, register_words): async def mock_modbus_fixture( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, check_config_loaded, config_addon, do_config, @@ -158,6 +160,15 @@ async def mock_modbus_fixture( result = await async_setup_component(hass, DOMAIN, config) assert result or not check_config_loaded await hass.async_block_till_done() + key = HassKey(DOMAIN) + if key not in hass.data: + return None + hub = hass.data[HassKey(DOMAIN)][TEST_MODBUS_NAME] + await hub.event_connected.wait() + assert hub.event_connected.is_set() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() return mock_pymodbus diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 54d4c5f6666..f661dd2083c 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -467,7 +467,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, value=0xAA, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xAA, device_id=10) await hass.services.async_call( CLIMATE_DOMAIN, @@ -477,7 +477,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, value=0xFF, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xFF, device_id=10) @pytest.mark.parametrize( @@ -506,7 +506,7 @@ async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_coil.assert_called_with(11, value=1, slave=10) + mock_modbus.write_coil.assert_called_with(11, value=1, device_id=10) await hass.services.async_call( CLIMATE_DOMAIN, @@ -516,7 +516,7 @@ async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_coil.assert_called_with(11, value=0, slave=10) + mock_modbus.write_coil.assert_called_with(11, value=0, device_id=10) @pytest.mark.parametrize( @@ -794,6 +794,140 @@ async def test_hvac_onoff_coil_update( assert state.state == result +@pytest.mark.parametrize( + ( + "do_config", + "result_before", + "coil_value_before", + "result_after", + "coil_value_after", + ), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.OFF, + [0x00], + HVACMode.AUTO, + [0x01], + ), + ], +) +async def test_hvac_onoff_coil_transition_update( + hass: HomeAssistant, + mock_modbus_ha, + result_before, + coil_value_before, + result_after, + coil_value_after, +) -> None: + """Test climate update based on On/Off coil values without hvacmode register.""" + mock_modbus_ha.read_coils.return_value = ReadResult(coil_value_before) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_before + + mock_modbus_ha.read_coils.return_value = ReadResult(coil_value_after) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_after + + +@pytest.mark.parametrize( + ( + "do_config", + "result_before", + "register_value_before", + "result_after", + "register_value_after", + ), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_HVAC_ONOFF_REGISTER: 11, + }, + ] + }, + HVACMode.OFF, + [0x00], + HVACMode.AUTO, + [0x01], + ), + ], +) +async def test_hvac_onoff_register_transition_update( + hass: HomeAssistant, + mock_modbus_ha, + result_before, + register_value_before, + result_after, + register_value_after, +) -> None: + """Test climate update based on On/Off register values without hvacmode register.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult( + register_value_before + ) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_before + + mock_modbus_ha.read_holding_registers.return_value = ReadResult( + register_value_after + ) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result_after + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 7b76dbc3528..3816e9878cb 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -63,6 +63,7 @@ from homeassistant.components.modbus.const import ( CONF_SWING_MODE_VALUES, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, + DEVICE_ID, MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, SERIAL, @@ -867,7 +868,7 @@ async def test_pb_service_write( assert func_name[do_write[FUNC]].called assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) assert func_name[do_write[FUNC]].call_args.kwargs == { - "slave": 17, + DEVICE_ID: 17, value_arg_name[do_write[FUNC]]: data[do_write[DATA]], } @@ -919,6 +920,9 @@ async def mock_modbus_read_pymodbus_fixture( freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 60)) async_fire_time_changed(hass) await hass.async_block_till_done() + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() return mock_pymodbus @@ -1087,11 +1091,11 @@ async def test_delay( start_time = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, config) is True await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state in (STATE_UNKNOWN, STATE_UNAVAILABLE) time_sensor_active = start_time + timedelta(seconds=2) time_after_delay = start_time + timedelta(seconds=(set_delay)) - time_after_scan = start_time + timedelta(seconds=(set_delay + set_scan_interval)) + time_after_scan = time_after_delay + timedelta(seconds=(set_scan_interval)) time_stop = time_after_scan + timedelta(seconds=10) now = start_time while now < time_stop: @@ -1104,8 +1108,13 @@ async def test_delay( await hass.async_block_till_done() if now > time_sensor_active: if now <= time_after_delay: - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - elif now > time_after_scan: + assert hass.states.get(entity_id).state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ) + if now <= time_after_delay + timedelta(seconds=2): + continue + if now > time_after_scan + timedelta(seconds=2): assert hass.states.get(entity_id).state == STATE_ON @@ -1224,6 +1233,7 @@ async def test_integration_reload( assert not state_sensor_2 +@pytest.mark.skip @pytest.mark.parametrize("do_config", [{}]) async def test_integration_reload_failed( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus @@ -1326,4 +1336,90 @@ async def test_check_default_slave( """Test default slave.""" assert mock_modbus.read_holding_registers.mock_calls first_call = mock_modbus.read_holding_registers.mock_calls[0] - assert first_call.kwargs["slave"] == expected_slave_value + assert first_call.kwargs[DEVICE_ID] == expected_slave_value + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy_noslave", + CONF_ADDRESS: 8888, + } + ], + }, + ], +) +@pytest.mark.parametrize( + "do_write", + [ + { + DATA: ATTR_VALUE, + VALUE: 15, + SERVICE: SERVICE_WRITE_REGISTER, + FUNC: CALL_TYPE_WRITE_REGISTER, + }, + { + DATA: ATTR_STATE, + VALUE: False, + SERVICE: SERVICE_WRITE_COIL, + FUNC: CALL_TYPE_WRITE_COIL, + }, + ], +) +@pytest.mark.parametrize( + "do_return", + [ + {VALUE: ReadResult([0x0001]), DATA: ""}, + {VALUE: ExceptionResponse(0x06), DATA: "Pymodbus:"}, + {VALUE: ModbusException("fail write_"), DATA: "Pymodbus:"}, + ], +) +async def test_pb_service_write_no_slave( + hass: HomeAssistant, + do_write, + do_return, + caplog: pytest.LogCaptureFixture, + mock_modbus_with_pymodbus, +) -> None: + """Run test for service write_register in case of missing slave/unit parameter.""" + + func_name = { + CALL_TYPE_WRITE_COIL: mock_modbus_with_pymodbus.write_coil, + CALL_TYPE_WRITE_REGISTER: mock_modbus_with_pymodbus.write_register, + } + + value_arg_name = { + CALL_TYPE_WRITE_COIL: "value", + CALL_TYPE_WRITE_REGISTER: "value", + } + + data = { + ATTR_HUB: TEST_MODBUS_NAME, + ATTR_ADDRESS: 16, + do_write[DATA]: do_write[VALUE], + } + mock_modbus_with_pymodbus.reset_mock() + caplog.clear() + caplog.set_level(logging.DEBUG) + func_name[do_write[FUNC]].return_value = do_return[VALUE] + await hass.services.async_call(DOMAIN, do_write[SERVICE], data, blocking=True) + assert func_name[do_write[FUNC]].called + assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) + assert func_name[do_write[FUNC]].call_args.kwargs == { + DEVICE_ID: 1, + value_arg_name[do_write[FUNC]]: data[do_write[DATA]], + } + + if do_return[DATA]: + assert any(message.startswith("Pymodbus:") for message in caplog.messages) diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 5fd6b11c8fe..bfa8ad3a0ef 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -2,12 +2,190 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import mold_indicator +from homeassistant.components.mold_indicator.config_flow import ( + MoldIndicatorConfigFlowHandler, +) +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_NAME +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def indoor_humidity_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_humidity_device( + device_registry: dr.DeviceRegistry, indoor_humidity_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_humidity_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:ED")}, + ) + + +@pytest.fixture +def indoor_humidity_entity_entry( + entity_registry: er.EntityRegistry, + indoor_humidity_config_entry: ConfigEntry, + indoor_humidity_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_humidity", + config_entry=indoor_humidity_config_entry, + device_id=indoor_humidity_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def indoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_temperature_device( + device_registry: dr.DeviceRegistry, indoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def indoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + indoor_temperature_config_entry: ConfigEntry, + indoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_temperature", + config_entry=indoor_temperature_config_entry, + device_id=indoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def outdoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def outdoor_temperature_device( + device_registry: dr.DeviceRegistry, outdoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=outdoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def outdoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + outdoor_temperature_config_entry: ConfigEntry, + outdoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_outdoor_temperature", + config_entry=outdoor_temperature_config_entry, + device_id=outdoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def mold_indicator_config_entry( + hass: HomeAssistant, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a mold_indicator config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=MoldIndicatorConfigFlowHandler.VERSION, + minor_version=MoldIndicatorConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + indoor_humidity_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return indoor_humidity_device.id if request.param == "humidity_device_id" else None + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" @@ -15,3 +193,500 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert await hass.config_entries.async_unload(loaded_entry.entry_id) await hass.async_block_till_done() assert loaded_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test cleaning of devices linked to the helper config entry.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "indoor", + "humidity", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.indoor_humidity") is not None + + # Configure the configuration entry for helper + helper_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="Test", + ) + helper_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to config entry + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, 3 devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_before_reload) == 2 + + # Config entry reload + await hass.config_entries.async_reload(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_after_reload) == 0 + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check if the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("sensor.test_unique_indoor_humidity", 1, None, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", 0, "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity from the device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "unload_entry_calls", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", 1, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, []), + ("sensor.test_unique_outdoor_temperature", 0, []), + ], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Move the source entity to another device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + indoor_humidity_entity_entry = entity_registry.async_get( + indoor_humidity_entity_entry.entity_id + ) + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + # Check that the mold_indicator config entry is not in any of the devices + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "config_key"), + [ + ("sensor.test_unique_indoor_humidity", CONF_INDOOR_HUMIDITY), + ("sensor.test_unique_indoor_temperature", CONF_INDOOR_TEMP), + ("sensor.test_unique_outdoor_temperature", CONF_OUTDOOR_TEMP), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the mold_indicator config entry is updated with the new entity ID + assert mold_indicator_config_entry.options[config_key] == "sensor.new_entity_id" + + # Check that the helper config is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + indoor_humidity_device: dr.DeviceEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes mold_indicator config entry from device.""" + + mold_indicator_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=1, + minor_version=1, + ) + mold_indicator_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + indoor_humidity_device.id, + add_config_entry_id=mold_indicator_config_entry.entry_id, + ) + + # Check preconditions + switch_device = device_registry.async_get(indoor_humidity_device.id) + assert mold_indicator_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + assert mold_indicator_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + assert mold_indicator_config_entry.version == 1 + assert mold_indicator_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 8d942e7a2a1..f3c4820ff90 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -532,7 +532,7 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert CONF_STREAM_URL_TEMPLATE not in result["data"] assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 result = await hass.config_entries.options.async_init( config_entry.entry_id, context={"show_advanced_options": True} @@ -551,4 +551,4 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert result["data"][CONF_STREAM_URL_TEMPLATE] == "http://moo" assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index bc345c0b66f..4e9d5e926a8 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -116,7 +116,6 @@ async def test_setup_camera_with_wrong_webhook( ) assert not client.async_set_camera.called - # Update the options, which will trigger a reload with the new behavior. with patch( "homeassistant.components.motioneye.MotionEyeClient", return_value=client, @@ -124,6 +123,7 @@ async def test_setup_camera_with_wrong_webhook( hass.config_entries.async_update_entry( config_entry, options={CONF_WEBHOOK_SET_OVERWRITE: True} ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() device = device_registry.async_get_device( diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index b985a8caffe..fdaed0c323f 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -32,7 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HassJobType, HomeAssistant from homeassistant.generated.mqtt import MQTT -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -71,6 +75,7 @@ MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT = { "platform": "binary_sensor", "name": "Hatch", "device_class": "door", + "entity_category": None, "state_topic": "test-topic", "payload_on": "ON", "payload_off": "OFF", @@ -86,17 +91,130 @@ MOCK_SUBENTRY_BUTTON_COMPONENT = { "name": "Restart", "device_class": "restart", "command_topic": "test-topic", + "entity_category": None, "payload_press": "PRESS", "command_template": "{{ value }}", "retain": False, "entity_picture": "https://example.com/365d05e6607c4dfb8ae915cff71a954b", }, } +MOCK_SUBENTRY_CLIMATE_COMPONENT = { + "b085c09efba7ec76acd94e2e0f851386": { + "platform": "climate", + "name": "Cooler", + "entity_category": None, + "entity_picture": "https://example.com/b085c09efba7ec76acd94e2e0f851386", + "temperature_unit": "C", + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # single target temperature + "temperature_command_topic": "temperature-command-topic", + "temperature_command_template": "{{ value }}", + "temperature_state_topic": "temperature-state-topic", + "temperature_state_template": "{{ value_json.temperature }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + # power settings + "power_command_topic": "power-command-topic", + "power_command_template": "{{ value }}", + "payload_on": "ON", + "payload_off": "OFF", + # current action settings + "action_topic": "action-topic", + "action_template": "{{ value_json.current_action }}", + # target humidity + "target_humidity_command_topic": "target-humidity-command-topic", + "target_humidity_command_template": "{{ value }}", + "target_humidity_state_topic": "target-humidity-state-topic", + "target_humidity_state_template": "{{ value_json.target_humidity }}", + "min_humidity": 20, + "max_humidity": 80, + # current temperature + "current_temperature_topic": "current-temperature-topic", + "current_temperature_template": "{{ value_json.temperature }}", + # current humidity + "current_humidity_topic": "current-humidity-topic", + "current_humidity_template": "{{ value_json.humidity }}", + # preset mode + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_command_template": "{{ value }}", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_mode_value_template": "{{ value_json.preset_mode }}", + "preset_modes": ["auto", "eco"], + # fan mode + "fan_mode_command_topic": "fan-mode-command-topic", + "fan_mode_command_template": "{{ value }}", + "fan_mode_state_topic": "fan-mode-state-topic", + "fan_mode_state_template": "{{ value_json.fan_mode }}", + "fan_modes": ["off", "low", "medium", "high"], + # swing mode + "swing_mode_command_topic": "swing-mode-command-topic", + "swing_mode_command_template": "{{ value }}", + "swing_mode_state_topic": "swing-mode-state-topic", + "swing_mode_state_template": "{{ value_json.swing_mode }}", + "swing_modes": ["off", "on"], + # swing horizontal mode + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-command-topic", + "swing_horizontal_mode_command_template": "{{ value }}", + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", + "swing_horizontal_mode_state_template": "{{ value_json.swing_horizontal_mode }}", + "swing_horizontal_modes": ["off", "on"], + }, +} +MOCK_SUBENTRY_CLIMATE_HIGH_LOW_COMPONENT = { + "b085c09efba7ec76acd94e2e0f851387": { + "platform": "climate", + "name": "Cooler", + "entity_category": None, + "entity_picture": "https://example.com/b085c09efba7ec76acd94e2e0f851387", + "temperature_unit": "C", + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # high/low target temperature + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, +} +MOCK_SUBENTRY_CLIMATE_NO_TARGET_TEMP_COMPONENT = { + "b085c09efba7ec76acd94e2e0f851388": { + "platform": "climate", + "name": "Cooler", + "entity_category": None, + "entity_picture": "https://example.com/b085c09efba7ec76acd94e2e0f851388", + "temperature_unit": "C", + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + }, +} MOCK_SUBENTRY_COVER_COMPONENT = { "b37acf667fa04c688ad7dfb27de2178b": { "platform": "cover", "name": "Blind", "device_class": "blind", + "entity_category": None, "command_topic": "test-topic", "payload_stop": None, "payload_stop_tilt": "STOP", @@ -132,6 +250,7 @@ MOCK_SUBENTRY_FAN_COMPONENT = { "platform": "fan", "name": "Breezer", "command_topic": "test-topic", + "entity_category": None, "state_topic": "test-topic", "command_template": "{{ value }}", "value_template": "{{ value_json.value }}", @@ -169,6 +288,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", "name": "Milkman alert", + "entity_category": None, "command_topic": "test-topic", "command_template": "{{ value }}", "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", @@ -179,6 +299,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { "6494827dac294fa0827c54b02459d309": { "platform": "notify", "name": "The second notifier", + "entity_category": None, "command_topic": "test-topic2", "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", }, @@ -187,6 +308,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "5269352dd9534c908d22812ea5d714cd": { "platform": "notify", "name": None, + "entity_category": None, "command_topic": "test-topic", "command_template": "{{ value }}", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", @@ -198,6 +320,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", "name": "Energy", + "entity_category": None, "device_class": "enum", "state_topic": "test-topic", "options": ["low", "medium", "high"], @@ -210,6 +333,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { "a0f85790a95d4889924602effff06b6e": { "platform": "sensor", "name": "Energy", + "entity_category": None, "state_class": "measurement", "state_topic": "test-topic", "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", @@ -219,6 +343,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", "name": "Energy", + "entity_category": None, "state_class": "total", "last_reset_value_template": "{{ value_json.value }}", "state_topic": "test-topic", @@ -229,6 +354,7 @@ MOCK_SUBENTRY_SWITCH_COMPONENT = { "3faf1318016c46c5aea26707eeb6f12e": { "platform": "switch", "name": "Outlet", + "entity_category": None, "device_class": "outlet", "command_topic": "test-topic", "state_topic": "test-topic", @@ -250,6 +376,7 @@ MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { "payload_off": "OFF", "payload_on": "ON", "command_topic": "test-topic", + "entity_category": None, "schema": "basic", "state_topic": "test-topic", "color_temp_kelvin": True, @@ -300,6 +427,18 @@ MOCK_BUTTON_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, "components": MOCK_SUBENTRY_BUTTON_COMPONENT, } +MOCK_CLIMATE_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_CLIMATE_COMPONENT, +} +MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_CLIMATE_HIGH_LOW_COMPONENT, +} +MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, + "components": MOCK_SUBENTRY_CLIMATE_NO_TARGET_TEMP_COMPONENT, +} MOCK_COVER_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_COVER_COMPONENT, @@ -1280,13 +1419,14 @@ async def help_test_entity_device_info_with_identifier( config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) config["unique_id"] = "veryunique" - registry = dr.async_get(hass) + area_registry = ar.async_get(hass) + device_registry = dr.async_get(hass) data = json.dumps(config) async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -1295,7 +1435,7 @@ async def help_test_entity_device_info_with_identifier( assert device.model_id == "XYZ001" assert device.hw_version == "rev1" assert device.sw_version == "0.1-beta" - assert device.suggested_area == "default_area" + assert device.area_id == area_registry.async_get_area_by_name("default_area").id assert device.configuration_url == "http://example.com" @@ -1315,13 +1455,14 @@ async def help_test_entity_device_info_with_connection( config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_MAC) config["unique_id"] = "veryunique" - registry = dr.async_get(hass) + area_registry = ar.async_get(hass) + device_registry = dr.async_get(hass) data = json.dumps(config) async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None @@ -1332,7 +1473,7 @@ async def help_test_entity_device_info_with_connection( assert device.model_id == "XYZ001" assert device.hw_version == "rev1" assert device.sw_version == "0.1-beta" - assert device.suggested_area == "default_area" + assert device.area_id == area_registry.async_get_area_by_name("default_area").id assert device.configuration_url == "http://example.com" diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 568fb7ea39d..333febe8844 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -29,10 +29,12 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.mqtt.climate import ( - DEFAULT_INITIAL_TEMPERATURE, MQTT_CLIMATE_ATTRIBUTES_BLOCKED, VALUE_TEMPLATE_KEYS, ) +from homeassistant.components.mqtt.const import ( + DEFAULT_CLIMATE_INITIAL_TEMPERATURE as DEFAULT_INITIAL_TEMPERATURE, +) from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index a139f729cd9..3b4f090aef3 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -35,6 +35,9 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_BUTTON_SUBENTRY_DATA_SINGLE, + MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE, + MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, MOCK_COVER_SUBENTRY_DATA_SINGLE, MOCK_FAN_SUBENTRY_DATA_SINGLE, MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, @@ -2700,6 +2703,224 @@ async def test_migrate_of_incompatible_config_entry( ), "Milk notifier Restart", ), + ( + MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": True, + "climate_feature_current_humidity": True, + "climate_feature_current_temperature": True, + "climate_feature_power": True, + "climate_feature_preset_modes": True, + "climate_feature_fan_modes": True, + "climate_feature_swing_horizontal_modes": True, + "climate_feature_swing_modes": True, + "climate_feature_target_temperature": "single", + "climate_feature_target_humidity": True, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # single target temperature + "target_temperature_settings": { + "temperature_command_topic": "temperature-command-topic", + "temperature_command_template": "{{ value }}", + "temperature_state_topic": "temperature-state-topic", + "temperature_state_template": "{{ value_json.temperature }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + # power settings + "climate_power_settings": { + "power_command_topic": "power-command-topic", + "power_command_template": "{{ value }}", + "payload_on": "ON", + "payload_off": "OFF", + }, + # current action settings + "climate_action_settings": { + "action_topic": "action-topic", + "action_template": "{{ value_json.current_action }}", + }, + # target humidity + "target_humidity_settings": { + "target_humidity_command_topic": "target-humidity-command-topic", + "target_humidity_command_template": "{{ value }}", + "target_humidity_state_topic": "target-humidity-state-topic", + "target_humidity_state_template": "{{ value_json.target_humidity }}", + "min_humidity": 20, + "max_humidity": 80, + }, + # current temperature + "current_temperature_settings": { + "current_temperature_topic": "current-temperature-topic", + "current_temperature_template": "{{ value_json.temperature }}", + }, + # current humidity + "current_humidity_settings": { + "current_humidity_topic": "current-humidity-topic", + "current_humidity_template": "{{ value_json.humidity }}", + }, + # preset mode + "climate_preset_mode_settings": { + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_command_template": "{{ value }}", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_mode_value_template": "{{ value_json.preset_mode }}", + "preset_modes": ["auto", "eco"], + }, + # fan mode + "climate_fan_mode_settings": { + "fan_mode_command_topic": "fan-mode-command-topic", + "fan_mode_command_template": "{{ value }}", + "fan_mode_state_topic": "fan-mode-state-topic", + "fan_mode_state_template": "{{ value_json.fan_mode }}", + "fan_modes": ["off", "low", "medium", "high"], + }, + # swing mode + "climate_swing_mode_settings": { + "swing_mode_command_topic": "swing-mode-command-topic", + "swing_mode_command_template": "{{ value }}", + "swing_mode_state_topic": "swing-mode-state-topic", + "swing_mode_state_template": "{{ value_json.swing_mode }}", + "swing_modes": ["off", "on"], + }, + # swing horizontal mode + "climate_swing_horizontal_mode_settings": { + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-command-topic", + "swing_horizontal_mode_command_template": "{{ value }}", + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", + "swing_horizontal_mode_state_template": "{{ value_json.swing_horizontal_mode }}", + "swing_horizontal_modes": ["off", "on"], + }, + }, + ( + ( + { + "modes": ["off", "heat", "cool", "auto"], + "target_temperature_settings": { + "temperature_command_topic": "test-topic#invalid" + }, + }, + {"target_temperature_settings": "invalid_publish_topic"}, + ), + ( + { + "modes": [], + "target_temperature_settings": { + "temperature_command_topic": "test-topic" + }, + }, + {"modes": "empty_list_not_allowed"}, + ), + ( + { + "modes": ["off", "heat", "cool", "auto"], + "target_temperature_settings": { + "temperature_command_topic": "test-topic", + "min_temp": 19.0, + "max_temp": 18.0, + }, + "target_humidity_settings": { + "target_humidity_command_topic": "test-topic", + "min_humidity": 50, + "max_humidity": 40, + }, + "climate_preset_mode_settings": { + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["none"], + }, + }, + { + "target_temperature_settings": "max_below_min_temperature", + "target_humidity_settings": "max_below_min_humidity", + "climate_preset_mode_settings": "preset_mode_none_not_allowed", + }, + ), + ), + "Milk notifier Cooler", + ), + ( + MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + }, + (), + "Milk notifier Cooler", + ), + ( + MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "none", + "climate_feature_target_humidity": False, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + }, + (), + "Milk notifier Cooler", + ), ( MOCK_COVER_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, @@ -2941,8 +3162,8 @@ async def test_migrate_of_incompatible_config_entry( MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, {"name": "Milkman alert"}, - None, - None, + {}, + (), { "command_topic": "test-topic", "command_template": "{{ value }}", @@ -2960,8 +3181,8 @@ async def test_migrate_of_incompatible_config_entry( MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {}, - None, - None, + {}, + (), { "command_topic": "test-topic", "command_template": "{{ value }}", @@ -3130,6 +3351,9 @@ async def test_migrate_of_incompatible_config_entry( ids=[ "binary_sensor", "button", + "climate_single", + "climate_high_low", + "climate_no_target_temp", "cover", "fan", "notify_with_entity_name", @@ -3143,6 +3367,7 @@ async def test_migrate_of_incompatible_config_entry( async def test_subentry_configflow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + mock_reload_after_entry_update: MagicMock, config_subentries_data: dict[str, Any], mock_device_user_input: dict[str, Any], mock_entity_user_input: dict[str, Any], @@ -3220,37 +3445,32 @@ async def test_subentry_configflow( "url": learn_more_url(component["platform"]), } - # Process extra step if the platform supports it - if mock_entity_details_user_input is not None: - # Extra entity details flow step - assert result["step_id"] == "entity_platform_config" + # Process entity details step + assert result["step_id"] == "entity_platform_config" - # First test validators if set of test - for failed_user_input, failed_errors in mock_entity_details_failed_user_input: - # Test an invalid entity details user input case - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input=failed_user_input, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == failed_errors - - # Now try again with valid data + # First test validators if set of test + for failed_user_input, failed_errors in mock_entity_details_failed_user_input: + # Test an invalid entity details user input case result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input=mock_entity_details_user_input, + user_input=failed_user_input, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - assert result["description_placeholders"] == { - "mqtt_device": device_name, - "platform": component["platform"], - "entity": entity_name, - "url": learn_more_url(component["platform"]), - } - else: - # No details form step - assert result["step_id"] == "mqtt_platform_config" + assert result["errors"] == failed_errors + + # Now try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_entity_details_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } # Process mqtt platform config flow # Test an invalid mqtt user input case @@ -3282,6 +3502,10 @@ async def test_subentry_configflow( assert subentry_device_data[option] == value await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + # Assert the entry is reloaded to set up the entity + assert len(mock_reload_after_entry_update.mock_calls) == 1 @pytest.mark.parametrize( @@ -3349,6 +3573,7 @@ async def test_subentry_reconfigure_remove_entity( "delete_entity", "device", "availability", + "export", ] # assert we can delete an entity @@ -3421,6 +3646,7 @@ async def test_subentry_reconfigure_remove_entity( async def test_subentry_reconfigure_edit_entity_multi_entitites( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + mock_reload_after_entry_update: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, user_input_mqtt: dict[str, Any], @@ -3470,6 +3696,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "delete_entity", "device", "availability", + "export", ] # assert we can update an entity @@ -3501,6 +3728,16 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( }, ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the platform specific entity data with changed entity_category + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "entity_category": "config", + }, + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data @@ -3527,6 +3764,10 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( for key, value in user_input_mqtt.items(): assert new_components[object_list[1]][key] == value + # Assert the entry is reloaded to set up the entity + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_reload_after_entry_update.mock_calls) == 1 + @pytest.mark.parametrize( ( @@ -3547,7 +3788,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( ), ), (), - None, + {}, { "command_topic": "test-topic1-updated", "command_template": "{{ value }}", @@ -3608,8 +3849,8 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( title="Mock subentry", ), ), - None, - None, + (), + {}, { "command_topic": "test-topic1-updated", "state_topic": "test-topic1-updated", @@ -3624,8 +3865,144 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( }, {"optimistic", "state_value_template", "entity_picture"}, ), + ( + ( + ConfigSubentryData( + data=MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + { + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + }, + {}, + { + "current_humidity_topic", + "action_topic", + "swing_modes", + "max_humidity", + "fan_modes", + "action_template", + "current_temperature_template", + "temperature_state_template", + "entity_picture", + "target_humidity_state_template", + "fan_mode_state_topic", + "swing_horizontal_mode_command_template", + "power_command_template", + "swing_horizontal_modes", + "current_temperature_topic", + "temperature_command_topic", + "swing_mode_command_topic", + "fan_mode_command_template", + "swing_horizontal_mode_state_template", + "preset_mode_command_template", + "swing_mode_command_template", + "temperature_state_topic", + "preset_mode_value_template", + "fan_mode_state_template", + "swing_horizontal_mode_command_topic", + "min_humidity", + "temperature_command_template", + "preset_modes", + "swing_horizontal_mode_state_topic", + "target_humidity_state_topic", + "target_humidity_command_topic", + "preset_mode_command_topic", + "payload_on", + "payload_off", + "power_command_topic", + "current_humidity_template", + "preset_mode_state_topic", + "fan_mode_command_topic", + "swing_mode_state_template", + "target_humidity_command_template", + "swing_mode_state_topic", + }, + ), + ( + ( + ConfigSubentryData( + data=MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + { + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + }, + {}, + {"entity_picture"}, + ), ], - ids=["notify", "sensor", "light_basic"], + ids=["notify", "sensor", "light_basic", "climate_single", "climate_high_low"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, @@ -3636,7 +4013,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( tuple[dict[str, Any], dict[str, str] | None], ... ] | None, - user_input_platform_config: dict[str, Any] | None, + user_input_platform_config: dict[str, Any], user_input_mqtt: dict[str, Any], component_data: dict[str, Any], removed_options: tuple[str, ...], @@ -3678,6 +4055,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3694,28 +4072,25 @@ async def test_subentry_reconfigure_edit_entity_single_entity( user_input={}, ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" - if user_input_platform_config is None: - # Skip entity flow step - assert result["step_id"] == "mqtt_platform_config" - else: - # Additional entity flow step - assert result["step_id"] == "entity_platform_config" - for entity_validation_config, errors in user_input_platform_config_validation: - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input=entity_validation_config, - ) - assert result["step_id"] == "entity_platform_config" - assert result.get("errors") == errors - assert result["type"] is FlowResultType.FORM - + # entity platform config flow step + assert result["step_id"] == "entity_platform_config" + for entity_validation_config, errors in user_input_platform_config_validation: result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input=user_input_platform_config, + user_input=entity_validation_config, ) + assert result["step_id"] == "entity_platform_config" + assert result.get("errors") == errors assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_platform_config, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data, result = await hass.config_entries.subentries.async_configure( @@ -3821,6 +4196,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3880,7 +4256,12 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( @pytest.mark.parametrize( - ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"), + ( + "mqtt_config_subentries_data", + "user_input_entity", + "user_input_entity_platform_config", + "user_input_mqtt", + ), [ ( ( @@ -3895,6 +4276,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( "name": "The second notifier", "entity_picture": "https://example.com", }, + {"entity_category": "diagnostic"}, { "command_topic": "test-topic2", }, @@ -3908,6 +4290,7 @@ async def test_subentry_reconfigure_add_entity( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, user_input_entity: dict[str, Any], + user_input_entity_platform_config: dict[str, Any], user_input_mqtt: dict[str, Any], ) -> None: """Test the subentry ConfigFlow reconfigure and add an entity.""" @@ -3944,6 +4327,7 @@ async def test_subentry_reconfigure_add_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3960,6 +4344,14 @@ async def test_subentry_reconfigure_add_entity( user_input=user_input_entity, ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the new entity platform config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity_platform_config, + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data @@ -4041,6 +4433,7 @@ async def test_subentry_reconfigure_update_device_properties( "delete_entity", "device", "availability", + "export", ] # assert we can update the device properties @@ -4056,10 +4449,11 @@ async def test_subentry_reconfigure_update_device_properties( result["flow_id"], user_input={ "name": "Beer notifier", - "sw_version": "1.1", + "advanced_settings": {"sw_version": "1.1"}, "model": "Beer bottle XL", "model_id": "bn003", "configuration_url": "https://example.com", + "mqtt_settings": {"qos": 1}, }, ) assert result["type"] is FlowResultType.MENU @@ -4073,12 +4467,15 @@ async def test_subentry_reconfigure_update_device_properties( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - # Check our device was updated + # Check our device and mqtt data was updated correctly device = deepcopy(dict(subentry.data))["device"] assert device["name"] == "Beer notifier" assert "hw_version" not in device assert device["model"] == "Beer bottle XL" assert device["model_id"] == "bn003" + assert device["sw_version"] == "1.1" + assert device["mqtt_settings"]["qos"] == 1 + assert "qos" not in device @pytest.mark.parametrize( @@ -4191,3 +4588,146 @@ async def test_subentry_reconfigure_availablity( "payload_available": "1", "payload_not_available": "0", } + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "field_suggestions"), + [ + ("export_yaml", {"yaml": "identifiers:\n - {}\n"}), + ( + "export_discovery", + { + "discovery_topic": "homeassistant/device/{}/config", + "discovery_payload": '"identifiers": [\n "{}"\n', + }, + ), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + flow_step: str, + field_suggestions: dict[str, str], +) -> None: + """Test the subentry ConfigFlow reconfigure export feature.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Assert the export is correct + for field in result["data_schema"].schema: + assert ( + field_suggestions[field].format(subentry_id) + in field.description["suggested_value"] + ) + + # Back to summary menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + +async def test_subentry_configflow_section_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow sections are hidden when they have no configurable options.""" + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Bla", "mqtt_settings": {"qos": 1}}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"platform": "fan"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == { + "mqtt_device": "Bla", + "platform": "fan", + "entity": "Bla", + "url": learn_more_url("fan"), + } + + # Process entity details step + assert result["step_id"] == "entity_platform_config" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"fan_feature_speed": True}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "mqtt_platform_config" + + # Check mqtt platform config flow sections from data schema + data_schema = result["data_schema"].schema + assert "fan_speed_settings" in data_schema + assert "fan_preset_mode_settings" not in data_schema diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 35a9a0494a6..04b4bda0d79 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1496,6 +1496,52 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered +async def test_discovery_with_object_id_for_previous_deleted_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test discovering an MQTT entity with object_id and unique_id.""" + + topic = "homeassistant/sensor/object/bla/config" + config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "hello_id", "state_topic": "test-topic" }' + ) + new_config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "updated_hello_id", "state_topic": "test-topic" }' + ) + initial_entity_id = "sensor.hello_id" + new_entity_id = "sensor.updated_hello_id" + name = "Hello World 11" + domain = "sensor" + + await mqtt_mock_entry() + async_fire_mqtt_message(hass, topic, config) + await hass.async_block_till_done() + + state = hass.states.get(initial_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + # Delete the entity + async_fire_mqtt_message(hass, topic, "") + await hass.async_block_till_done() + assert (domain, "object bla") not in hass.data["mqtt"].discovery_already_discovered + + # Rediscover with new object_id + async_fire_mqtt_message(hass, topic, new_config) + await hass.async_block_till_done() + + state = hass.states.get(new_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + async def test_discovery_incl_nodeid( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index f789d7f3be1..1aeb9843b54 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -604,6 +604,23 @@ def test_entity_device_info_schema() -> None: ) +@pytest.mark.parametrize( + ("side_effect", "error_message"), + [ + ( + ValueError("Invalid value for sensor"), + "Value error while updating " + "state of sensor.test_sensor, topic: 'test/state' " + "with payload: b'payload causing errors'", + ), + ( + TypeError("Invalid value for sensor"), + "Exception raised while updating " + "state of sensor.test_sensor, topic: 'test/state' " + "with payload: b'payload causing errors'", + ), + ], +) @pytest.mark.parametrize( "hass_config", [ @@ -625,6 +642,8 @@ async def test_handle_logging_on_writing_the_entity_state( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + side_effect: Exception, + error_message: str, ) -> None: """Test on log handling when an error occurs writing the state.""" await mqtt_mock_entry() @@ -637,7 +656,7 @@ async def test_handle_logging_on_writing_the_entity_state( assert state.state == "initial_state" with patch( "homeassistant.helpers.entity.Entity.async_write_ha_state", - side_effect=ValueError("Invalid value for sensor"), + side_effect=side_effect, ): async_fire_mqtt_message(hass, "test/state", b"payload causing errors") await hass.async_block_till_done() @@ -645,11 +664,7 @@ async def test_handle_logging_on_writing_the_entity_state( assert state is not None assert state.state == "initial_state" assert "Invalid value for sensor" in caplog.text - assert ( - "Exception raised while updating " - "state of sensor.test_sensor, topic: 'test/state' " - "with payload: b'payload causing errors'" in caplog.text - ) + assert error_message in caplog.text async def test_receiving_non_utf8_message_gets_logged( diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index fd54e5f0643..9d5dc8f0a8a 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -26,6 +26,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_UNIT_OF_MEASUREMENT, + UnitOfElectricPotential, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State @@ -253,6 +254,62 @@ async def test_native_value_validation( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "name": "test", + "command_topic": "test-topic-cmd", + "state_topic": "test-topic", + "unit_of_measurement": "\u00b5V", + } + } + } + ], +) +async def test_equivalent_unit_of_measurement( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class with equivalent unit of measurement.""" + assert await mqtt_mock_entry() + async_fire_mqtt_message(hass, "test-topic", "100") + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state is not None + assert state.state == "100" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + is UnitOfElectricPotential.MICROVOLT + ) + + caplog.clear() + + discovery_payload = { + "name": "bla", + "command_topic": "test-topic2-cmd", + "state_topic": "test-topic2", + "unit_of_measurement": "\u00b5V", + } + # Now discover an invalid sensor + async_fire_mqtt_message( + hass, "homeassistant/number/bla/config", json.dumps(discovery_payload) + ) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic2", "21") + await hass.async_block_till_done() + state = hass.states.get("number.bla") + assert state is not None + assert state.state == "21" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + is UnitOfElectricPotential.MICROVOLT + ) + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_repairs.py b/tests/components/mqtt/test_repairs.py new file mode 100644 index 00000000000..bc7b9dd4294 --- /dev/null +++ b/tests/components/mqtt/test_repairs.py @@ -0,0 +1,179 @@ +"""Test repairs for MQTT.""" + +from collections.abc import Coroutine +from copy import deepcopy +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.util.yaml import parse_yaml + +from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message + +from tests.common import MockConfigEntry, async_capture_events +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.conftest import ClientSessionGenerator +from tests.typing import MqttMockHAClientGenerator + + +async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + with patch( + "homeassistant.config.load_yaml_config_file", + return_value=parse_yaml(config["yaml"]), + ): + await hass.services.async_call( + mqtt.DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def help_setup_discovery(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + async_fire_mqtt_message( + hass, config["discovery_topic"], config["discovery_payload"] + ) + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "setup_helper", "translation_key"), + [ + ("export_yaml", help_setup_yaml, "subentry_migration_yaml"), + ("export_discovery", help_setup_discovery, "subentry_migration_discovery"), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + hass_client: ClientSessionGenerator, + flow_step: str, + setup_helper: Coroutine[Any, Any, None], + translation_key: str, +) -> None: + """Test the subentry ConfigFlow YAML export with migration to YAML.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Copy the exported config suggested values for an export + suggested_values_from_schema = { + field: field.description["suggested_value"] + for field in result["data_schema"].schema + } + # Try to set up the exported config with a changed device name + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + + # Assert the subentry device was not effected by the exported configs + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # Assert a repair flow was created + # This happens when the exported device identifier was detected + # The subentry ID is used as device identifier + assert len(events) == 1 + issue_id = events[0].data["issue_id"] + issue_registry = ir.async_get(hass) + repair_issue = issue_registry.async_get_issue(mqtt.DOMAIN, issue_id) + assert repair_issue.translation_key == translation_key + + await async_process_repairs_platforms(hass) + client = await hass_client() + + data = await start_repair_fix_flow(client, mqtt.DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"name": "Milk notifier"} + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, flow_id) + assert data["type"] == "create_entry" + + # Assert the subentry is removed and no other entity has linked the device + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is None + + await hass.async_block_till_done(wait_background_tasks=True) + assert len(config_entry.subentries) == 0 + + # Try to set up the exported config again + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + assert len(events) == 0 + + # The MQTT device was now set up from the new source + await hass.async_block_till_done(wait_background_tasks=True) + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {None} + assert device is not None diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 997c014cd13..f7198095aa2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -15,9 +15,11 @@ import pytest from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt.sensor import MQTT_SENSOR_ATTRIBUTES_BLOCKED from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfElectricPotential, UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant, State, callback @@ -906,6 +908,116 @@ async def test_invalid_unit_of_measurement( assert state is None +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "voltage", + "unit_of_measurement": "\u00b5V", # microVolt + } + } + } + ], +) +async def test_device_class_with_equivalent_unit_of_measurement_received( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class with equivalent unit of measurement.""" + assert await mqtt_mock_entry() + async_fire_mqtt_message(hass, "test-topic", "100") + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "100" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + is UnitOfElectricPotential.MICROVOLT + ) + + caplog.clear() + + discovery_payload = { + "name": "bla", + "state_topic": "test-topic2", + "device_class": "voltage", + "unit_of_measurement": "\u00b5V", + } + # Now discover a sensor with an altarantive mu char + async_fire_mqtt_message( + hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) + ) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic2", "21") + await hass.async_block_till_done() + state = hass.states.get("sensor.bla") + assert state is not None + assert state.state == "21" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + is UnitOfElectricPotential.MICROVOLT + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "\u00b5V", + } + } + } + ], +) +async def test_equivalent_unit_of_measurement_received_without_device_class( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class with equivalent unit of measurement.""" + assert await mqtt_mock_entry() + async_fire_mqtt_message(hass, "test-topic", "100") + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "100" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + is UnitOfElectricPotential.MICROVOLT + ) + + caplog.clear() + + discovery_payload = { + "name": "bla", + "state_topic": "test-topic2", + "unit_of_measurement": "\u00b5V", + } + # Now discover an invalid sensor + async_fire_mqtt_message( + hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) + ) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic2", "21") + await hass.async_block_till_done() + state = hass.states.get("sensor.bla") + assert state is not None + assert state.state == "21" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + is UnitOfElectricPotential.MICROVOLT + ) + + @pytest.mark.parametrize( "hass_config", [ @@ -924,6 +1036,30 @@ async def test_invalid_unit_of_measurement( "device_class": None, "unit_of_measurement": None, }, + { + "name": "Test 4", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": "", + }, + { + "name": "Test 5", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": " ", + }, + { + "name": "Test 6", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": "", + }, + { + "name": "Test 7", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": " ", + }, ] } } @@ -936,10 +1072,25 @@ async def test_valid_device_class_and_uom( await mqtt_mock_entry() state = hass.states.get("sensor.test_1") + assert state is not None assert state.attributes["device_class"] == "temperature" state = hass.states.get("sensor.test_2") + assert state is not None assert "device_class" not in state.attributes state = hass.states.get("sensor.test_3") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_4") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_5") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_6") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_7") + assert state is not None assert "device_class" not in state.attributes diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index ba404e2dff0..b0c5981fbe1 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -2,6 +2,7 @@ from copy import deepcopy import json +import logging from typing import Any from unittest.mock import patch @@ -32,6 +33,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from .common import ( help_custom_config, @@ -108,7 +110,7 @@ async def test_default_supported_features( entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( - ["start", "stop", "return_home", "battery", "clean_spot"] + ["start", "stop", "return_home", "clean_spot"] ) @@ -313,8 +315,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.CLEANING - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" assert state.attributes.get(ATTR_FAN_SPEED) == "max" message = """{ @@ -326,8 +326,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.DOCKED - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 assert state.attributes.get(ATTR_FAN_SPEED) == "min" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] @@ -337,6 +335,78 @@ async def test_status( assert state.state == STATE_UNKNOWN +# Use of the battery feature was deprecated in HA Core 2025.8 +# and will be removed with HA Core 2026.2 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ({mqttvacuum.CONF_SUPPORTED_FEATURES: ["battery"]},), + ) + ], +) +async def test_status_with_deprecated_battery_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test status updates from the vacuum with deprecated battery feature.""" + await mqtt_mock_entry() + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + + message = """{ + "battery_level": 54, + "state": "cleaning" + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.CLEANING + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" + + message = """{ + "battery_level": 61, + "state": "docked" + }""" + + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.DOCKED + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 + + message = '{"state":null}' + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + assert ( + "MQTT vacuum entity vacuum.mqtttest implements " + "the battery feature which is deprecated." in caplog.text + ) + + # assert a repair issue was created for the entity + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + mqtt.DOMAIN, "deprecated_vacuum_battery_feature_vacuum.mqtttest" + ) + assert issue is not None + assert issue.issue_domain == "vacuum" + assert issue.translation_key == "deprecated_vacuum_battery_feature" + assert issue.translation_placeholders == {"entity_id": "vacuum.mqtttest"} + assert not [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert ( + "mqtt' is setting the battery_level which has been deprecated" + ) not in caplog.text + + @pytest.mark.parametrize( "hass_config", [ @@ -346,7 +416,9 @@ async def test_status( ( { mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING + mqttvacuum.DEFAULT_SERVICES + | vacuum.VacuumEntityFeature.BATTERY, + SERVICE_TO_STRING, ) }, ), diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index a98ae82fbe1..072b1ece1a1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +import inspect from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -191,7 +191,7 @@ async def trigger_subscription_callback( object_id=object_id, data=data, ) - if asyncio.iscoroutinefunction(cb_func): + if inspect.iscoroutinefunction(cb_func): await cb_func(event) else: cb_func(event) diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2b397891d6f..5eefccbcda9 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -53,6 +53,7 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.connect = AsyncMock(side_effect=connect) client.start_listening = AsyncMock(side_effect=listen) + client.send_command = AsyncMock(return_value=None) client.server_info = ServerInfoMessage( server_id=MOCK_SERVER_ID, server_version="0.0.0", diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 58ce20da824..5116c97a6ae 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -4,7 +4,6 @@ "player_id": "00:00:00:00:00:01", "provider": "test", "type": "player", - "name": "Test Player 1", "available": true, "powered": false, "device_info": { @@ -23,10 +22,10 @@ ], "elapsed_time": null, "elapsed_time_last_updated": 0, - "state": "idle", + "playback_state": "idle", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "00:00:00:00:00:01", "active_group": null, "current_media": null, @@ -37,7 +36,7 @@ "enabled": true, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "Test Player 1", + "name": "Test Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -75,7 +74,6 @@ "player_id": "00:00:00:00:00:02", "provider": "test", "type": "player", - "name": "Test Player 2", "available": true, "powered": true, "device_info": { @@ -93,10 +91,10 @@ ], "elapsed_time": 0, "elapsed_time_last_updated": 0, - "state": "playing", + "playback_state": "playing", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "spotify", "active_group": null, "current_media": { @@ -117,7 +115,7 @@ "hidden": false, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "My Super Test Player 2", + "name": "My Super Test Player 2", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -139,7 +137,6 @@ "player_id": "test_group_player_1", "provider": "player_group", "type": "group", - "name": "Test Group Player 1", "available": true, "powered": true, "device_info": { @@ -157,10 +154,10 @@ ], "elapsed_time": 0.0, "elapsed_time_last_updated": 1730315437.9904983, - "state": "idle", + "playback_state": "idle", "volume_level": 6, "volume_muted": false, - "group_childs": ["00:00:00:00:00:01", "00:00:00:00:00:02"], + "group_members": ["00:00:00:00:00:01", "00:00:00:00:00:02"], "active_source": "test_group_player_1", "active_group": null, "current_media": { @@ -180,7 +177,7 @@ "enabled": true, "icon": "mdi-speaker-multiple", "group_volume": 6, - "display_name": "Test Group Player 1", + "name": "Test Group Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", diff --git a/tests/components/music_assistant/snapshots/test_button.ambr b/tests/components/music_assistant/snapshots/test_button.ambr index ac9e4c660f6..d064916e044 100644 --- a/tests/components/music_assistant/snapshots/test_button.ambr +++ b/tests/components/music_assistant/snapshots/test_button.ambr @@ -140,6 +140,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index c13ea342262..27253ae2b20 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -11,12 +11,12 @@ from homeassistant.components.music_assistant.actions import ( SERVICE_SEARCH, ) from homeassistant.components.music_assistant.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_MEDIA_TYPE, ATTR_SEARCH_NAME, DOMAIN, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from .common import create_library_albums_from_fixture, setup_integration_from_fixtures diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py index 8a1a4b0e241..432430b4223 100644 --- a/tests/components/music_assistant/test_button.py +++ b/tests/components/music_assistant/test_button.py @@ -2,14 +2,20 @@ from unittest.mock import MagicMock, call +from music_assistant_models.enums import EventType +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) async def test_button_entities( @@ -46,3 +52,35 @@ async def test_button_press_action( "music/favorites/add_item", item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", ) + + # test again without current_media + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].current_media = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + # test again without active source + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].active_source = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr index 14be11c36ec..66b4c9efe35 100644 --- a/tests/components/myuplink/snapshots/test_init.ambr +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Jäspi', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '10001', - 'suggested_area': None, 'sw_version': '9682R7A', 'via_device_id': None, }) @@ -50,7 +48,6 @@ 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Nibe', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '10002', - 'suggested_area': None, 'sw_version': '9682R7B', 'via_device_id': None, }) @@ -83,7 +79,6 @@ 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Nibe', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '10003', - 'suggested_area': None, 'sw_version': '9682R7C', 'via_device_id': None, }) diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py index c531d193359..e1063c108e4 100644 --- a/tests/components/nam/__init__.py +++ b/tests/components/nam/__init__.py @@ -33,7 +33,10 @@ async def init_integration( update_response = Mock(json=AsyncMock(return_value=nam_data)) with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( "homeassistant.components.nam.NettigoAirMonitor._async_http_request", return_value=update_response, diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index cc6bc9bc7b6..3071752267e 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -981,7 +981,7 @@ 'supported_features': 0, 'translation_key': 'pmsx003_pm1', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_p0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_pmsx003_pm1-state] @@ -990,7 +990,7 @@ 'device_class': 'pm1', 'friendly_name': 'Nettigo Air Monitor PMSx003 PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_pmsx003_pm1', @@ -1037,7 +1037,7 @@ 'supported_features': 0, 'translation_key': 'pmsx003_pm10', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_p1', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_pmsx003_pm10-state] @@ -1046,7 +1046,7 @@ 'device_class': 'pm10', 'friendly_name': 'Nettigo Air Monitor PMSx003 PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_pmsx003_pm10', @@ -1093,7 +1093,7 @@ 'supported_features': 0, 'translation_key': 'pmsx003_pm25', 'unique_id': 'aa:bb:cc:dd:ee:ff-pms_p2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_pmsx003_pm2_5-state] @@ -1102,7 +1102,7 @@ 'device_class': 'pm25', 'friendly_name': 'Nettigo Air Monitor PMSx003 PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_pmsx003_pm2_5', @@ -1261,7 +1261,7 @@ 'supported_features': 0, 'translation_key': 'sds011_pm10', 'unique_id': 'aa:bb:cc:dd:ee:ff-sds011_p1', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_sds011_pm10-state] @@ -1270,7 +1270,7 @@ 'device_class': 'pm10', 'friendly_name': 'Nettigo Air Monitor SDS011 PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_sds011_pm10', @@ -1317,7 +1317,7 @@ 'supported_features': 0, 'translation_key': 'sds011_pm25', 'unique_id': 'aa:bb:cc:dd:ee:ff-sds011_p2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_sds011_pm2_5-state] @@ -1326,7 +1326,7 @@ 'device_class': 'pm25', 'friendly_name': 'Nettigo Air Monitor SDS011 PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_sds011_pm2_5', @@ -1653,7 +1653,7 @@ 'supported_features': 0, 'translation_key': 'sps30_pm1', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p0', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_sps30_pm1-state] @@ -1662,7 +1662,7 @@ 'device_class': 'pm1', 'friendly_name': 'Nettigo Air Monitor SPS30 PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_sps30_pm1', @@ -1709,7 +1709,7 @@ 'supported_features': 0, 'translation_key': 'sps30_pm10', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p1', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_sps30_pm10-state] @@ -1718,7 +1718,7 @@ 'device_class': 'pm10', 'friendly_name': 'Nettigo Air Monitor SPS30 PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_sps30_pm10', @@ -1765,7 +1765,7 @@ 'supported_features': 0, 'translation_key': 'sps30_pm25', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_sps30_pm2_5-state] @@ -1774,7 +1774,7 @@ 'device_class': 'pm25', 'friendly_name': 'Nettigo Air Monitor SPS30 PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_sps30_pm2_5', @@ -1821,7 +1821,7 @@ 'supported_features': 0, 'translation_key': 'sps30_pm4', 'unique_id': 'aa:bb:cc:dd:ee:ff-sps30_p4', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor[sensor.nettigo_air_monitor_sps30_pm4-state] @@ -1829,7 +1829,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nettigo Air Monitor SPS30 PM4', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.nettigo_air_monitor_sps30_pm4', diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 80c6e86f420..e3c2397de77 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -1,7 +1,8 @@ """Define tests for the Nettigo Air Monitor config flow.""" +from collections.abc import Generator from ipaddress import ip_address -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest @@ -26,11 +27,21 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) VALID_CONFIG = {"host": "10.10.2.3"} VALID_AUTH = {"username": "fake_username", "password": "fake_password"} -DEVICE_CONFIG = {"www_basicauth_enabled": False} -DEVICE_CONFIG_AUTH = {"www_basicauth_enabled": True} -async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_form_create_entry_without_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step without auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -39,18 +50,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -64,7 +66,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: +async def test_form_create_entry_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step with auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -73,18 +77,9 @@ async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=[AuthFailedError("Authorization has failed"), "aa:bb:cc:dd:ee:ff"], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -121,23 +116,17 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: @@ -154,7 +143,7 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -162,8 +151,8 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_unsuccessful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" @pytest.mark.parametrize( @@ -178,15 +167,9 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: """Test we handle errors when auth is required.""" exc, base_error = error - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Authorization has failed"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -198,7 +181,7 @@ async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: assert result["step_id"] == "credentials" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=exc, ): result = await hass.config_entries.flow.async_configure( @@ -236,10 +219,6 @@ async def test_form_errors(hass: HomeAssistant, error) -> None: async def test_form_abort(hass: HomeAssistant) -> None: """Test we handle abort after error.""" with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=CannotGetMacError("Cannot get MAC address from device"), @@ -266,15 +245,9 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -288,17 +261,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf(hass: HomeAssistant) -> None: +async def test_zeroconf(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -316,15 +283,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert context["title_placeholders"]["host"] == "10.10.2.3" assert context["confirm_only"] is True - with patch( - "homeassistant.components.nam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "10.10.2.3" @@ -332,17 +292,13 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: +async def test_zeroconf_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the zeroconf step with auth works.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Auth Error"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -360,18 +316,9 @@ async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: assert result["errors"] == {} assert context["title_placeholders"]["host"] == "10.10.2.3" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -447,15 +394,9 @@ async def test_reconfigure_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -491,7 +432,7 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -503,15 +444,9 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "cannot_connect"} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -546,15 +481,9 @@ async def test_reconfigure_not_the_same_device(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 13bde1432b3..ea61739c008 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -44,27 +44,6 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_config_not_ready_while_checking_credentials(hass: HomeAssistant) -> None: - """Test for setup failure if the connection fails while checking credentials.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="10.10.2.3", - unique_id="aa:bb:cc:dd:ee:ff", - data={"host": "10.10.2.3"}, - ) - entry.add_to_hass(hass) - - with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=ApiError("API Error"), - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY - - async def test_config_auth_failed(hass: HomeAssistant) -> None: """Test for setup failure if the auth fails.""" entry = MockConfigEntry( @@ -76,7 +55,7 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=AuthFailedError("Authorization has failed"), ): await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index ba89405bc97..d9616572b2e 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -10,6 +10,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.nanoleaf.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -463,3 +464,59 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_discovery_flow_with_user_flow(hass: HomeAssistant) -> None: + """Test abort discovery flow if user flow is already in progress.""" + with ( + patch( + "homeassistant.components.nanoleaf.config_flow.load_json_object", + return_value={}, + ), + patch( + "homeassistant.components.nanoleaf.config_flow.Nanoleaf", + return_value=_mock_nanoleaf(TEST_HOST, TEST_TOKEN), + ), + patch( + "homeassistant.components.nanoleaf.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={}, + ssdp_headers={ + "_host": TEST_HOST, + "nl-devicename": TEST_NAME, + "nl-deviceid": TEST_DEVICE_ID, + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "link" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "link" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + + # Verify the discovery flow was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index 22a50213306..e5d5f477d34 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -147,6 +147,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -229,6 +230,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 22, @@ -312,6 +314,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -396,6 +399,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 12, diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 35e7f7efc29..3f8d924bdbf 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '0009999992', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Bubbendorf', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -50,7 +48,6 @@ '0009999993', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Bubbendorf', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -83,7 +79,6 @@ '00:11:22:33:00:11:45:fe', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -116,7 +110,6 @@ '1002003001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Smarther', @@ -126,7 +119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Corridor', 'sw_version': None, 'via_device_id': None, }) @@ -149,7 +141,6 @@ '12:34:56:00:00:a1:4c:da', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -159,7 +150,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -182,7 +172,6 @@ '12:34:56:00:01:01:01:a1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -192,7 +181,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -215,7 +203,6 @@ '12:34:56:00:16:0e#0', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -225,7 +212,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -248,7 +234,6 @@ '12:34:56:00:16:0e#1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -258,7 +243,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -281,7 +265,6 @@ '12:34:56:00:16:0e#2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -291,7 +274,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -314,7 +296,6 @@ '12:34:56:00:16:0e#3', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -324,7 +305,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -347,7 +327,6 @@ '12:34:56:00:16:0e#4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -357,7 +336,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -380,7 +358,6 @@ '12:34:56:00:16:0e#5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -390,7 +367,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -413,7 +389,6 @@ '12:34:56:00:16:0e#6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -423,7 +398,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -446,7 +420,6 @@ '12:34:56:00:16:0e#7', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -456,7 +429,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -479,7 +451,6 @@ '12:34:56:00:16:0e#8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -489,7 +460,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -512,7 +482,6 @@ '12:34:56:00:16:0e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -522,7 +491,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -545,7 +513,6 @@ '12:34:56:00:f1:62', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -555,7 +522,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -578,7 +544,6 @@ '12:34:56:03:1b:e4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -588,7 +553,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -611,7 +575,6 @@ '12:34:56:10:b9:0e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -621,7 +584,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -644,7 +606,6 @@ '12:34:56:10:f1:66', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -654,7 +615,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -677,7 +637,6 @@ '12:34:56:25:cf:a8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -687,7 +646,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -710,7 +668,6 @@ '12:34:56:26:65:14', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -720,7 +677,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -743,7 +699,6 @@ '12:34:56:26:68:92', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -753,7 +708,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -776,7 +730,6 @@ '12:34:56:26:69:0c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -786,7 +739,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -809,7 +761,6 @@ '12:34:56:3e:c5:46', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -819,7 +770,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -842,7 +792,6 @@ '12:34:56:80:00:12:ac:f2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -852,7 +801,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -875,7 +823,6 @@ '12:34:56:80:1c:42', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -885,7 +832,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -908,7 +854,6 @@ '12:34:56:80:44:92', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -918,7 +863,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -941,7 +885,6 @@ '12:34:56:80:7e:18', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -951,7 +894,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -974,7 +916,6 @@ '12:34:56:80:bb:26', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -984,7 +925,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1007,7 +947,6 @@ '12:34:56:80:c1:ea', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1017,7 +956,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1040,7 +978,6 @@ '222452125', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1050,7 +987,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Bureau', 'sw_version': None, 'via_device_id': None, }) @@ -1073,7 +1009,6 @@ '2746182631', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1083,7 +1018,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Livingroom', 'sw_version': None, 'via_device_id': None, }) @@ -1106,7 +1040,6 @@ '2833524037', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1116,7 +1049,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Entrada', 'sw_version': None, 'via_device_id': None, }) @@ -1139,7 +1071,6 @@ '2940411577', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1149,7 +1080,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Cocina', 'sw_version': None, 'via_device_id': None, }) @@ -1172,7 +1102,6 @@ '91763b24c43d3e344f424e8b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1182,7 +1111,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1205,7 +1133,6 @@ 'Home avg', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1215,7 +1142,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1238,7 +1164,6 @@ 'Home max', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1248,7 +1173,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1271,7 +1195,6 @@ 'Home min', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1281,7 +1204,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index f38e21021dc..0344ec8a7c1 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -681,6 +681,13 @@ async def test_service_schedule_thermostats( webhook_id = config_entry.data[CONF_WEBHOOK_ID] climate_entity_livingroom = "climate.livingroom" + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "591b54a2764ff4d50d8b5795" + ) + # Test setting a valid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_schedule: await hass.services.async_call( @@ -707,6 +714,12 @@ async def test_service_schedule_thermostats( hass.states.get(climate_entity_livingroom).attributes["selected_schedule"] == "Winter" ) + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "b1b54a2f45795764f59d50d8" + ) # Test setting an invalid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule: diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index 2a806be8ae1..fd58e6e0002 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'FFFFFFFFFFFFF', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netgear', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'FFFFFFFFFFFFF', - 'suggested_area': None, 'sw_version': 'EC25AFFDR07A09M4G', 'via_device_id': None, }) diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 4cf74d72e63..ef46eecaa66 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -13,8 +13,6 @@ from nextdns import ( Settings, ) -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -39,6 +37,7 @@ SETTINGS = Settings( ai_threat_detection=True, allow_affiliate=True, anonymized_ecs=True, + bav=True, block_bypass_methods=True, block_csam=True, block_ddns=True, @@ -154,20 +153,12 @@ def mock_nextdns(): yield -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Set up the NextDNS integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - entry_id="d9aa37407ddac7b964a99e86312288d6", - ) - - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with mock_nextdns(): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - - return entry diff --git a/tests/components/nextdns/conftest.py b/tests/components/nextdns/conftest.py new file mode 100644 index 00000000000..b46c51d673c --- /dev/null +++ b/tests/components/nextdns/conftest.py @@ -0,0 +1,32 @@ +"""Common fixtures for the NextDNS tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nextdns.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Fake Profile", + unique_id="xyz12", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, + entry_id="d9aa37407ddac7b964a99e86312288d6", + ) diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 23f42fee077..f55c381af4e 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -56,6 +56,7 @@ 'ai_threat_detection': True, 'allow_affiliate': True, 'anonymized_ecs': True, + 'bav': True, 'block_9gag': True, 'block_amazon': True, 'block_bereal': True, diff --git a/tests/components/nextdns/snapshots/test_switch.ambr b/tests/components/nextdns/snapshots/test_switch.ambr index 0b25baecd20..d2a78a61127 100644 --- a/tests/components/nextdns/snapshots/test_switch.ambr +++ b/tests/components/nextdns/snapshots/test_switch.ambr @@ -2879,6 +2879,54 @@ 'state': 'on', }) # --- +# name: test_switch[switch.fake_profile_bypass_age_verification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.fake_profile_bypass_age_verification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass age verification', + 'platform': 'nextdns', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass_age_verification', + 'unique_id': 'xyz12_bav', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.fake_profile_bypass_age_verification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fake Profile Bypass age verification', + }), + 'context': , + 'entity_id': 'switch.fake_profile_bypass_age_verification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch[switch.fake_profile_cache_boost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nextdns/test_binary_sensor.py b/tests/components/nextdns/test_binary_sensor.py index 99e40af0dce..c9ad0d6e209 100644 --- a/tests/components/nextdns/test_binary_sensor.py +++ b/tests/components/nextdns/test_binary_sensor.py @@ -3,56 +3,65 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the binary sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BINARY_SENSOR]): + await init_integration(hass, mock_config_entry) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) - future = utcnow() + timedelta(minutes=10) + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=10)) with patch( "homeassistant.components.nextdns.NextDns.connection_status", side_effect=ApiError("API Error"), ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("binary_sensor.fake_profile_device_connection_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 0cb4a7cd0df..03108e81984 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -15,31 +15,34 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import init_integration -from tests.common import snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform async def test_button( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the button.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.BUTTON]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_button_press(hass: HomeAssistant) -> None: +@pytest.mark.freeze_time("2023-10-21") +async def test_button_press( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test button press.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) - now = dt_util.utcnow() with ( patch("homeassistant.components.nextdns.NextDns.clear_logs") as mock_clear_logs, - patch("homeassistant.core.dt_util.utcnow", return_value=now), ): await hass.services.async_call( BUTTON_DOMAIN, @@ -53,7 +56,7 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.fake_profile_clear_logs") assert state - assert state.state == now.isoformat() + assert state.state == "2023-10-21T00:00:00+00:00" @pytest.mark.parametrize( @@ -65,9 +68,11 @@ async def test_button_press(hass: HomeAssistant) -> None: ClientError, ], ) -async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: +async def test_button_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Tests that the press action throws HomeAssistantError.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) with ( patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc), @@ -84,9 +89,11 @@ async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: ) -async def test_button_auth_error(hass: HomeAssistant) -> None: +async def test_button_auth_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Tests that the press action starts re-auth flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) with patch( "homeassistant.components.nextdns.NextDns.clear_logs", @@ -99,7 +106,7 @@ async def test_button_auth_error(hass: HomeAssistant) -> None: blocking=True, ) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -110,4 +117,4 @@ async def test_button_auth_error(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 27a6cf1e7e0..d577fb21845 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -1,6 +1,6 @@ """Define tests for the NextDNS config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nextdns import ApiError, InvalidApiKeyError import pytest @@ -14,8 +14,12 @@ from homeassistant.data_entry_flow import FlowResultType from . import PROFILES, init_integration, mock_nextdns +from tests.common import MockConfigEntry -async def test_form_create_entry(hass: HomeAssistant) -> None: + +async def test_form_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -24,14 +28,9 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nextdns.NextDns.get_profiles", - return_value=PROFILES, - ), - patch( - "homeassistant.components.nextdns.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,12 +43,12 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Fake Profile" assert result["data"][CONF_API_KEY] == "fake_api_key" assert result["data"][CONF_PROFILE_ID] == "xyz12" + assert result["result"].unique_id == "xyz12" assert len(mock_setup_entry.mock_calls) == 1 @@ -64,24 +63,55 @@ async def test_form_create_entry(hass: HomeAssistant) -> None: ], ) async def test_form_errors( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, exc: Exception, base_error: str ) -> None: """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_API_KEY: "fake_api_key"}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, ) + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": base_error} + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, + ) -async def test_form_already_configured(hass: HomeAssistant) -> None: + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "profiles" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Fake Profile" + assert result["data"][CONF_API_KEY] == "fake_api_key" + assert result["data"][CONF_PROFILE_ID] == "xyz12" + assert result["result"].unique_id == "xyz12" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that errors are shown when duplicates are added.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -103,11 +133,13 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test starting a reauthentication flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - result = await entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -122,7 +154,6 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -139,12 +170,15 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: ], ) async def test_reauth_errors( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauthentication flow with errors.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - result = await entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -155,6 +189,20 @@ async def test_reauth_errors( result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) - await hass.async_block_till_done() assert result["errors"] == {"base": base_error} + + with ( + patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, + ), + mock_nextdns(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/nextdns/test_coordinator.py b/tests/components/nextdns/test_coordinator.py index f2b353ea2c5..83748f836b5 100644 --- a/tests/components/nextdns/test_coordinator.py +++ b/tests/components/nextdns/test_coordinator.py @@ -12,17 +12,18 @@ from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_auth_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, ) -> None: """Test authentication error when polling data.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED freezer.tick(timedelta(minutes=10)) with ( @@ -62,7 +63,7 @@ async def test_auth_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -73,4 +74,4 @@ async def test_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 4a5e09908ec..2b0c0564649 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from . import init_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -15,10 +16,11 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( - exclude=props("created_at", "modified_at") - ) + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 0a0bf3fc487..217e75ca701 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -6,9 +6,9 @@ from nextdns import ApiError, InvalidApiKeyError import pytest from tenacity import RetryError -from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN +from homeassistant.components.nextdns.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import init_integration @@ -16,9 +16,11 @@ from . import init_integration from tests.common import MockConfigEntry -async def test_async_setup_entry(hass: HomeAssistant) -> None: +async def test_async_setup_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test a successful setup entry.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("sensor.fake_profile_dns_queries_blocked_ratio") assert state is not None @@ -29,55 +31,48 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "exc", [ApiError("API Error"), RetryError("Retry Error"), TimeoutError] ) -async def test_config_not_ready(hass: HomeAssistant, exc: Exception) -> None: +async def test_config_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Test for setup failure if the connection to the service fails.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - ) - with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc, ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test successful unload of entry.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) -async def test_config_auth_failed(hass: HomeAssistant) -> None: +async def test_config_auth_failed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test for setup failure if the auth fails.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Fake Profile", - unique_id="xyz12", - data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with patch( "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=InvalidApiKeyError, ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -88,4 +83,4 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index 43e823fbf38..3ef1ab55f9f 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError import pytest from syrupy.assertion import SnapshotAssertion @@ -10,11 +11,10 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -22,48 +22,35 @@ async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of sensors.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_availability( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_config_entry) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "100" + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "20" + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "75" - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "60" - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "90" - - future = utcnow() + timedelta(minutes=10) + freezer.tick(timedelta(minutes=10)) with ( patch( "homeassistant.components.nextdns.NextDns.get_analytics_status", @@ -86,55 +73,16 @@ async def test_availability( side_effect=ApiError("API Error"), ), ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state == STATE_UNAVAILABLE - - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.fake_profile_dns_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "100" - - state = hass.states.get("sensor.fake_profile_dns_over_https_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "20" - - state = hass.states.get("sensor.fake_profile_dnssec_validated_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "75" - - state = hass.states.get("sensor.fake_profile_encrypted_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "60" - - state = hass.states.get("sensor.fake_profile_ipv4_queries") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "90" + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 1b0edb2c83c..645ca11ac49 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError +from freezegun.api import FrozenDateTimeFactory from nextdns import ApiError, InvalidApiKeyError import pytest from syrupy.assertion import SnapshotAssertion @@ -25,11 +26,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration, mock_nextdns -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -37,17 +37,20 @@ async def test_switch( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the switches.""" with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_switch_on(hass: HomeAssistant) -> None: +async def test_switch_on( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the switch can be turned on.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("switch.fake_profile_block_page") assert state @@ -71,9 +74,11 @@ async def test_switch_on(hass: HomeAssistant) -> None: mock_switch_on.assert_called_once() -async def test_switch_off(hass: HomeAssistant) -> None: +async def test_switch_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the switch can be turned on.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) state = hass.states.get("switch.fake_profile_web3") assert state @@ -97,6 +102,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: mock_switch_on.assert_called_once() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "exc", [ @@ -105,36 +111,43 @@ async def test_switch_off(hass: HomeAssistant) -> None: TimeoutError, ], ) -async def test_availability(hass: HomeAssistant, exc: Exception) -> None: +async def test_availability( + hass: HomeAssistant, + exc: Exception, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) + with patch("homeassistant.components.nextdns.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mock_config_entry) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_ids = (entry.entity_id for entry in entity_entries) - future = utcnow() + timedelta(minutes=10) + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + freezer.tick(timedelta(minutes=10)) with patch( "homeassistant.components.nextdns.NextDns.get_settings", side_effect=exc, ): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state == STATE_UNAVAILABLE + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=20) + freezer.tick(timedelta(minutes=10)) with mock_nextdns(): - async_fire_time_changed(hass, future) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("switch.fake_profile_web3") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == STATE_ON + for entity_id in entity_ids: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -146,9 +159,11 @@ async def test_availability(hass: HomeAssistant, exc: Exception) -> None: ClientError, ], ) -async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: +async def test_switch_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exc: Exception +) -> None: """Tests that the turn on/off service throws HomeAssistantError.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) with ( patch("homeassistant.components.nextdns.NextDns.set_setting", side_effect=exc), @@ -162,9 +177,11 @@ async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: ) -async def test_switch_auth_error(hass: HomeAssistant) -> None: +async def test_switch_auth_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Tests that the turn on/off action starts re-auth flow.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) with patch( "homeassistant.components.nextdns.NextDns.set_setting", @@ -177,7 +194,7 @@ async def test_switch_auth_error(hass: HomeAssistant) -> None: blocking=True, ) - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -188,4 +205,4 @@ async def test_switch_auth_error(hass: HomeAssistant) -> None: assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/nibe_heatpump/__init__.py b/tests/components/nibe_heatpump/__init__.py index 15cd9859d6e..e5ce32b2293 100644 --- a/tests/components/nibe_heatpump/__init__.py +++ b/tests/components/nibe_heatpump/__init__.py @@ -24,6 +24,8 @@ MOCK_ENTRY_DATA = { "connection_type": "nibegw", } +MOCK_UNIQUE_ID = "mock_entry_unique_id" + class MockConnection(Connection): """A mock connection class.""" @@ -59,7 +61,9 @@ class MockConnection(Connection): async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> MockConfigEntry: """Add entry and get the coordinator.""" - entry = MockConfigEntry(domain=DOMAIN, title="Dummy", data=data) + entry = MockConfigEntry( + domain=DOMAIN, title="Dummy", data=data, unique_id=MOCK_UNIQUE_ID + ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr b/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..37dd7a8679c --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_update[Model.F1255-49239-OFF][binary_sensor.eb101_installed_49239-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'EB101 Installed', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'eb101_installed_49239', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-49239', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-49239-OFF][binary_sensor.eb101_installed_49239-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 EB101 Installed', + }), + 'context': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-49239-ON][binary_sensor.eb101_installed_49239-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'EB101 Installed', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'eb101_installed_49239', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-49239', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-49239-ON][binary_sensor.eb101_installed_49239-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 EB101 Installed', + }), + 'context': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr index 50755533ee5..965d5a3b2bb 100644 --- a/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr @@ -5,7 +5,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -22,7 +22,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -39,7 +39,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -56,7 +56,7 @@ 'friendly_name': 'S320 Min supply climate system 1', 'max': 80.0, 'min': 5.0, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -77,7 +77,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -94,7 +94,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -111,7 +111,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -128,7 +128,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , diff --git a/tests/components/nibe_heatpump/snapshots/test_number.ambr b/tests/components/nibe_heatpump/snapshots/test_number.ambr index 343d5569a2d..ac6354c902a 100644 --- a/tests/components/nibe_heatpump/snapshots/test_number.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_number.ambr @@ -1,11 +1,29 @@ # serializer version: 1 +# name: test_set_value_same + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1155 Room sensor setpoint S1', + 'max': 30.0, + 'min': 5.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.room_sensor_setpoint_s1_47398', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- # name: test_update[Model.F1155-47011-number.heat_offset_s1_47011--10] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'F1155 Heat Offset S1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -22,7 +40,7 @@ 'friendly_name': 'F1155 Heat Offset S1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -42,7 +60,7 @@ 'friendly_name': 'F750 HW charge offset', 'max': 12.7, 'min': -12.8, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -60,7 +78,7 @@ 'friendly_name': 'F750 HW charge offset', 'max': 12.7, 'min': -12.8, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -78,7 +96,7 @@ 'friendly_name': 'F750 HW charge offset', 'max': 12.7, 'min': -12.8, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -96,7 +114,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -113,7 +131,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -130,7 +148,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , diff --git a/tests/components/nibe_heatpump/snapshots/test_switch.ambr b/tests/components/nibe_heatpump/snapshots/test_switch.ambr new file mode 100644 index 00000000000..01f35bd8a54 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_switch.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_update[Model.F1255-48043-ACTIVE][switch.holiday_activated_48043-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.holiday_activated_48043', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Holiday - Activated', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'holiday_activated_48043', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48043', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48043-ACTIVE][switch.holiday_activated_48043-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 Holiday - Activated', + }), + 'context': , + 'entity_id': 'switch.holiday_activated_48043', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update[Model.F1255-48043-INACTIVE][switch.holiday_activated_48043-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.holiday_activated_48043', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Holiday - Activated', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'holiday_activated_48043', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48043', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48043-INACTIVE][switch.holiday_activated_48043-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 Holiday - Activated', + }), + 'context': , + 'entity_id': 'switch.holiday_activated_48043', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-48071-OFF][switch.flm_1_accessory_48071-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FLM 1 accessory', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'flm_1_accessory_48071', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48071', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48071-OFF][switch.flm_1_accessory_48071-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 FLM 1 accessory', + }), + 'context': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-48071-ON][switch.flm_1_accessory_48071-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FLM 1 accessory', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'flm_1_accessory_48071', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48071', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48071-ON][switch.flm_1_accessory_48071-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 FLM 1 accessory', + }), + 'context': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_binary_sensor.py b/tests/components/nibe_heatpump/test_binary_sensor.py new file mode 100644 index 00000000000..30010ac61c4 --- /dev/null +++ b/tests/components/nibe_heatpump/test_binary_sensor.py @@ -0,0 +1,49 @@ +"""Test the Nibe Heat Pump binary sensor entities.""" + +from typing import Any +from unittest.mock import patch + +from nibe.heatpump import Model +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_add_model + +from tests.common import snapshot_platform + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch( + "homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.mark.parametrize( + ("model", "address", "value"), + [ + (Model.F1255, 49239, "OFF"), + (Model.F1255, 49239, "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: Model, + address: int, + value: Any, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + entry = await async_add_model(hass, model) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 5015bba4092..4f2bab7ad0a 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump buttons.""" from typing import Any from unittest.mock import AsyncMock, patch diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index a9620b5ddb3..85e932f8018 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump climate entities.""" from typing import Any from unittest.mock import call, patch diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index dc7faf0a80e..6e004a0554e 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -1,9 +1,10 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump number entities.""" from typing import Any from unittest.mock import AsyncMock, patch from nibe.coil import CoilData +from nibe.exceptions import WriteDeniedException, WriteException, WriteTimeoutException from nibe.heatpump import Model import pytest from syrupy.assertion import SnapshotAssertion @@ -15,6 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import async_add_model @@ -108,3 +110,101 @@ async def test_set_value( assert isinstance(coil, CoilData) assert coil.coil.address == address assert coil.value == value + + +@pytest.mark.parametrize( + ("exception", "translation_key", "translation_placeholders"), + [ + ( + WriteTimeoutException("timeout writing"), + "write_timeout", + {"address": "47398"}, + ), + ( + WriteException("failed"), + "write_failed", + { + "address": "47398", + "value": "25.0", + "error": "failed", + }, + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_value_fail( + hass: HomeAssistant, + mock_connection: AsyncMock, + exception: Exception, + translation_key: str, + translation_placeholders: dict[str, Any], + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + + value = 25 + model = Model.F1155 + address = 47398 + entity_id = "number.room_sensor_setpoint_s1_47398" + coils[address] = 0 + + await async_add_model(hass, model) + + await hass.async_block_till_done() + assert hass.states.get(entity_id) + + mock_connection.write_coil.side_effect = exception + + # Write value + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + assert exc_info.value.translation_domain == "nibe_heatpump" + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == translation_placeholders + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_value_same( + hass: HomeAssistant, + mock_connection: AsyncMock, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting a value, which the pump will reject.""" + + value = 25 + model = Model.F1155 + address = 47398 + entity_id = "number.room_sensor_setpoint_s1_47398" + coils[address] = 0 + + await async_add_model(hass, model) + + await hass.async_block_till_done() + assert hass.states.get(entity_id) + + mock_connection.write_coil.side_effect = WriteDeniedException() + + # Write value + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + # Verify attempt was done + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.value == value + + # State should have been set + assert hass.states.get(entity_id) == snapshot diff --git a/tests/components/nibe_heatpump/test_switch.py b/tests/components/nibe_heatpump/test_switch.py new file mode 100644 index 00000000000..4221de52ba1 --- /dev/null +++ b/tests/components/nibe_heatpump/test_switch.py @@ -0,0 +1,133 @@ +"""Test the Nibe Heat Pump switch entities.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from nibe.coil import CoilData +from nibe.heatpump import Model +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_PLATFORM, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_add_model + +from tests.common import snapshot_platform + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.SWITCH]): + yield + + +@pytest.mark.parametrize( + ("model", "address", "value"), + [ + (Model.F1255, 48043, "INACTIVE"), + (Model.F1255, 48043, "ACTIVE"), + (Model.F1255, 48071, "OFF"), + (Model.F1255, 48071, "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: Model, + address: int, + value: Any, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + entry = await async_add_model(hass, model) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "state"), + [ + (Model.F1255, 48043, "switch.holiday_activated_48043", "INACTIVE"), + (Model.F1255, 48071, "switch.flm_1_accessory_48071", "OFF"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_turn_on( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + state: Any, + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + coils[address] = state + + await async_add_model(hass, model) + + # Write value + await hass.services.async_call( + SWITCH_PLATFORM, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.raw_value == 1 + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "state"), + [ + (Model.F1255, 48043, "switch.holiday_activated_48043", "INACTIVE"), + (Model.F1255, 48071, "switch.flm_1_accessory_48071", "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_turn_off( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + state: Any, + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + coils[address] = state + + await async_add_model(hass, model) + + # Write value + await hass.services.async_call( + SWITCH_PLATFORM, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.raw_value == 0 diff --git a/tests/components/nina/snapshots/test_diagnostics.ambr b/tests/components/nina/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..aaf42471912 --- /dev/null +++ b/tests/components/nina/snapshots/test_diagnostics.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + '083350000000': list([ + dict({ + 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', + 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', + 'expires': '3021-11-22T05:19:00+01:00', + 'headline': 'Ausfall Notruf 112', + 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', + 'is_valid': True, + 'recommended_actions': '', + 'sender': 'Deutscher Wetterdienst', + 'sent': '2021-10-11T05:20:00+01:00', + 'severity': 'Minor', + 'start': '2021-11-01T05:20:00+01:00', + 'web': 'https://www.wettergefahren.de', + }), + dict({ + 'affected_areas': 'Axstedt, Gnarrenburg, Grasberg, Hagen im Bremischen, Hambergen, Hepstedt, Holste, Lilienthal, Lübberstedt, Osterholz-Scharmbeck, Ritterhude, Schwanewede, Vollersode, Worpswede', + 'description': 'In Beverstedt im Landkreis Cuxhaven ist am 20. Juli 2022 in einer Geflügelhaltung der Ausbruch der Geflügelpest (Vogelgrippe, Aviäre Influenza) amtlich festgestellt worden. Durch die geografische Nähe des Ausbruchsbetriebes zum Gebiet des Landkreises Osterholz musste das Veterinäramt des Landkreises zum Schutz vor einer Ausbreitung der Geflügelpest auch für sein Gebiet ein Restriktionsgebiet festlegen. Rund um den Ausbruchsort wurde eine Überwachungszone ausgewiesen. Eine entsprechende Tierseuchenbehördliche Allgemeinverfügung wurde vom Landkreis Osterholz erlassen und tritt am 23.07.2022 in Kraft.
\xa0
Die Überwachungszone mit einem Radius von mindestens zehn Kilometern um den Ausbruchsbetrieb erstreckt sich im Landkreis Osterholz innerhalb der Samtgemeinde Hambergen auf die Mitgliedsgemeinden Axstedt, Holste und Lübberstedt. Die vorgenannten Gemeinden sind vollständig zur Überwachungszone erklärt worden. Der genaue Grenzverlauf des Gebietes kann auch der interaktiven Karte im Internet entnommen werden.
\xa0
In der Überwachungszone liegen im Landkreis Osterholz rund 70 Geflügelhaltungen mit einem Gesamtbestand von rund 1.800 Tieren. Sie alle unterliegen mit der Allgemeinverfügung der sogenannten amtlichen Beobachtung. Für die Betriebe sind die Biosicherheitsmaßnahmen einzuhalten. Dazu zählen insbesondere Hygienemaßnahmen im laufenden Betrieb und eine ordnungsgemäße Schadnagerbekämpfung.
\xa0
Das Verbringen von Vögeln, Fleisch von Geflügel, Eiern und sonstige Nebenprodukte von Geflügel in und aus Betrieben in der Überwachungszone ist verboten. Auch Geflügeltransporte sind in der Überwachungszone verboten. Jeder Verdacht der Erkrankung auf Geflügelpest ist zudem dem Veterinäramt des Landkreises Osterholz unter der E-Mail-Adresse veterinaeramt@landkreis-osterholz.de sofort zu melden. Alle Hinweise, die innerhalb der Überwachungszone zu beachten sind, sind unter www.landkreis-osterholz.de/gefluegelpest zusammengefasst dargestellt.
\xa0
Die Veterinärbehörde weist zudem darauf hin, dass sämtliche Geflügelhaltungen – Hühner, Enten, Gänse, Fasane, Perlhühner, Rebhühner, Truthühner, Wachteln oder Laufvögel – der zuständigen Behörde angezeigt werden müssen. Wer dies bisher noch nicht gemacht hat und über keine Registriernummer für seinen Geflügelbestand verfügt, sollte die Meldung über das Veterinäramt umgehend nachholen.
\xa0
Das Beobachtungsgebiet kann frühestens 30 Tage nach der Grobreinigung des Ausbruchsbetriebes wieder aufgehoben werden. Hierüber wird der Landkreis Osterholz informieren.
\xa0
Die Allgemeinverfügung, eine Übersicht zur Überwachungszone und weitere Hinweise sind auf der Internetseite unter www.landkreis-osterholz.de/gefluegelpest zu finden.', + 'expires': '2002-08-07T10:59:00+02:00', + 'headline': 'Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt', + 'id': 'biw.BIWAPP-69634', + 'is_valid': False, + 'recommended_actions': '', + 'sender': '', + 'sent': '1999-08-07T10:59:00+02:00', + 'severity': 'Minor', + 'start': '', + 'web': '', + }), + ]), + }), + 'entry_data': dict({ + 'area_filter': '.*', + 'headline_filter': '.*corona.*', + 'regions': dict({ + '083350000000': 'Aach, Stadt', + }), + 'slots': 5, + }), + }) +# --- diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 309c8860c20..06eb94d59d0 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -323,9 +323,6 @@ async def test_options_flow_entity_removal( "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ), - patch( - "homeassistant.components.nina._async_update_listener" - ) as mock_update_listener, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -352,4 +349,3 @@ async def test_options_flow_entity_removal( ) assert len(entries) == 2 - assert len(mock_update_listener.mock_calls) == 1 diff --git a/tests/components/nina/test_diagnostics.py b/tests/components/nina/test_diagnostics.py new file mode 100644 index 00000000000..c0646b8d68c --- /dev/null +++ b/tests/components/nina/test_diagnostics.py @@ -0,0 +1,45 @@ +"""Test the Nina diagnostics.""" + +from typing import Any +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nina.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import mocked_request_function + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +ENTRY_DATA: dict[str, Any] = { + "slots": 5, + "corona_filter": True, + "regions": {"083350000000": "Aach, Stadt"}, +} + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + config_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/nordpool/fixtures/indices_15.json b/tests/components/nordpool/fixtures/indices_15.json new file mode 100644 index 00000000000..63af9840098 --- /dev/null +++ b/tests/components/nordpool/fixtures/indices_15.json @@ -0,0 +1,689 @@ +{ + "deliveryDateCET": "2025-07-06", + "version": 2, + "updatedAt": "2025-07-05T10:56:42.3755929Z", + "market": "DayAhead", + "indexNames": ["SE3"], + "currency": "SEK", + "resolutionInMinutes": 15, + "areaStates": [ + { + "state": "Preliminary", + "areas": ["SE3"] + } + ], + "multiIndexEntries": [ + { + "deliveryStart": "2025-07-05T22:00:00Z", + "deliveryEnd": "2025-07-05T22:15:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T22:15:00Z", + "deliveryEnd": "2025-07-05T22:30:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T22:30:00Z", + "deliveryEnd": "2025-07-05T22:45:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T22:45:00Z", + "deliveryEnd": "2025-07-05T23:00:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T23:00:00Z", + "deliveryEnd": "2025-07-05T23:15:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-05T23:15:00Z", + "deliveryEnd": "2025-07-05T23:30:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-05T23:30:00Z", + "deliveryEnd": "2025-07-05T23:45:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-05T23:45:00Z", + "deliveryEnd": "2025-07-06T00:00:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-06T00:00:00Z", + "deliveryEnd": "2025-07-06T00:15:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T00:15:00Z", + "deliveryEnd": "2025-07-06T00:30:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T00:30:00Z", + "deliveryEnd": "2025-07-06T00:45:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T00:45:00Z", + "deliveryEnd": "2025-07-06T01:00:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T01:00:00Z", + "deliveryEnd": "2025-07-06T01:15:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T01:15:00Z", + "deliveryEnd": "2025-07-06T01:30:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T01:30:00Z", + "deliveryEnd": "2025-07-06T01:45:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T01:45:00Z", + "deliveryEnd": "2025-07-06T02:00:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T02:00:00Z", + "deliveryEnd": "2025-07-06T02:15:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T02:15:00Z", + "deliveryEnd": "2025-07-06T02:30:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T02:30:00Z", + "deliveryEnd": "2025-07-06T02:45:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T02:45:00Z", + "deliveryEnd": "2025-07-06T03:00:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T03:00:00Z", + "deliveryEnd": "2025-07-06T03:15:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T03:15:00Z", + "deliveryEnd": "2025-07-06T03:30:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T03:30:00Z", + "deliveryEnd": "2025-07-06T03:45:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T03:45:00Z", + "deliveryEnd": "2025-07-06T04:00:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T04:00:00Z", + "deliveryEnd": "2025-07-06T04:15:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T04:15:00Z", + "deliveryEnd": "2025-07-06T04:30:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T04:30:00Z", + "deliveryEnd": "2025-07-06T04:45:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T04:45:00Z", + "deliveryEnd": "2025-07-06T05:00:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T05:00:00Z", + "deliveryEnd": "2025-07-06T05:15:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T05:15:00Z", + "deliveryEnd": "2025-07-06T05:30:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T05:30:00Z", + "deliveryEnd": "2025-07-06T05:45:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T05:45:00Z", + "deliveryEnd": "2025-07-06T06:00:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T06:00:00Z", + "deliveryEnd": "2025-07-06T06:15:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T06:15:00Z", + "deliveryEnd": "2025-07-06T06:30:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T06:30:00Z", + "deliveryEnd": "2025-07-06T06:45:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T06:45:00Z", + "deliveryEnd": "2025-07-06T07:00:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T07:00:00Z", + "deliveryEnd": "2025-07-06T07:15:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T07:15:00Z", + "deliveryEnd": "2025-07-06T07:30:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T07:30:00Z", + "deliveryEnd": "2025-07-06T07:45:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T07:45:00Z", + "deliveryEnd": "2025-07-06T08:00:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T08:00:00Z", + "deliveryEnd": "2025-07-06T08:15:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T08:15:00Z", + "deliveryEnd": "2025-07-06T08:30:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T08:30:00Z", + "deliveryEnd": "2025-07-06T08:45:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T08:45:00Z", + "deliveryEnd": "2025-07-06T09:00:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T09:00:00Z", + "deliveryEnd": "2025-07-06T09:15:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T09:15:00Z", + "deliveryEnd": "2025-07-06T09:30:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T09:30:00Z", + "deliveryEnd": "2025-07-06T09:45:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T09:45:00Z", + "deliveryEnd": "2025-07-06T10:00:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T10:00:00Z", + "deliveryEnd": "2025-07-06T10:15:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T10:15:00Z", + "deliveryEnd": "2025-07-06T10:30:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T10:30:00Z", + "deliveryEnd": "2025-07-06T10:45:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T10:45:00Z", + "deliveryEnd": "2025-07-06T11:00:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T11:00:00Z", + "deliveryEnd": "2025-07-06T11:15:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T11:15:00Z", + "deliveryEnd": "2025-07-06T11:30:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T11:30:00Z", + "deliveryEnd": "2025-07-06T11:45:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T11:45:00Z", + "deliveryEnd": "2025-07-06T12:00:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T12:00:00Z", + "deliveryEnd": "2025-07-06T12:15:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T12:15:00Z", + "deliveryEnd": "2025-07-06T12:30:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T12:30:00Z", + "deliveryEnd": "2025-07-06T12:45:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T12:45:00Z", + "deliveryEnd": "2025-07-06T13:00:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T13:00:00Z", + "deliveryEnd": "2025-07-06T13:15:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T13:15:00Z", + "deliveryEnd": "2025-07-06T13:30:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T13:30:00Z", + "deliveryEnd": "2025-07-06T13:45:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T13:45:00Z", + "deliveryEnd": "2025-07-06T14:00:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T14:00:00Z", + "deliveryEnd": "2025-07-06T14:15:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T14:15:00Z", + "deliveryEnd": "2025-07-06T14:30:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T14:30:00Z", + "deliveryEnd": "2025-07-06T14:45:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T14:45:00Z", + "deliveryEnd": "2025-07-06T15:00:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T15:00:00Z", + "deliveryEnd": "2025-07-06T15:15:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T15:15:00Z", + "deliveryEnd": "2025-07-06T15:30:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T15:30:00Z", + "deliveryEnd": "2025-07-06T15:45:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T15:45:00Z", + "deliveryEnd": "2025-07-06T16:00:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T16:00:00Z", + "deliveryEnd": "2025-07-06T16:15:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T16:15:00Z", + "deliveryEnd": "2025-07-06T16:30:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T16:30:00Z", + "deliveryEnd": "2025-07-06T16:45:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T16:45:00Z", + "deliveryEnd": "2025-07-06T17:00:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T17:00:00Z", + "deliveryEnd": "2025-07-06T17:15:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T17:15:00Z", + "deliveryEnd": "2025-07-06T17:30:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T17:30:00Z", + "deliveryEnd": "2025-07-06T17:45:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T17:45:00Z", + "deliveryEnd": "2025-07-06T18:00:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T18:00:00Z", + "deliveryEnd": "2025-07-06T18:15:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T18:15:00Z", + "deliveryEnd": "2025-07-06T18:30:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T18:30:00Z", + "deliveryEnd": "2025-07-06T18:45:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T18:45:00Z", + "deliveryEnd": "2025-07-06T19:00:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T19:00:00Z", + "deliveryEnd": "2025-07-06T19:15:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T19:15:00Z", + "deliveryEnd": "2025-07-06T19:30:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T19:30:00Z", + "deliveryEnd": "2025-07-06T19:45:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T19:45:00Z", + "deliveryEnd": "2025-07-06T20:00:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T20:00:00Z", + "deliveryEnd": "2025-07-06T20:15:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T20:15:00Z", + "deliveryEnd": "2025-07-06T20:30:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T20:30:00Z", + "deliveryEnd": "2025-07-06T20:45:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T20:45:00Z", + "deliveryEnd": "2025-07-06T21:00:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T21:00:00Z", + "deliveryEnd": "2025-07-06T21:15:00Z", + "entryPerArea": { + "SE3": 396.38 + } + }, + { + "deliveryStart": "2025-07-06T21:15:00Z", + "deliveryEnd": "2025-07-06T21:30:00Z", + "entryPerArea": { + "SE3": 396.38 + } + }, + { + "deliveryStart": "2025-07-06T21:30:00Z", + "deliveryEnd": "2025-07-06T21:45:00Z", + "entryPerArea": { + "SE3": 396.38 + } + }, + { + "deliveryStart": "2025-07-06T21:45:00Z", + "deliveryEnd": "2025-07-06T22:00:00Z", + "entryPerArea": { + "SE3": 396.38 + } + } + ] +} diff --git a/tests/components/nordpool/fixtures/indices_60.json b/tests/components/nordpool/fixtures/indices_60.json new file mode 100644 index 00000000000..97bbe554b13 --- /dev/null +++ b/tests/components/nordpool/fixtures/indices_60.json @@ -0,0 +1,185 @@ +{ + "deliveryDateCET": "2025-07-06", + "version": 2, + "updatedAt": "2025-07-05T10:56:44.6936838Z", + "market": "DayAhead", + "indexNames": ["SE3"], + "currency": "SEK", + "resolutionInMinutes": 60, + "areaStates": [ + { + "state": "Preliminary", + "areas": ["SE3"] + } + ], + "multiIndexEntries": [ + { + "deliveryStart": "2025-07-05T22:00:00Z", + "deliveryEnd": "2025-07-05T23:00:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T23:00:00Z", + "deliveryEnd": "2025-07-06T00:00:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-06T00:00:00Z", + "deliveryEnd": "2025-07-06T01:00:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T01:00:00Z", + "deliveryEnd": "2025-07-06T02:00:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T02:00:00Z", + "deliveryEnd": "2025-07-06T03:00:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T03:00:00Z", + "deliveryEnd": "2025-07-06T04:00:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T04:00:00Z", + "deliveryEnd": "2025-07-06T05:00:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T05:00:00Z", + "deliveryEnd": "2025-07-06T06:00:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T06:00:00Z", + "deliveryEnd": "2025-07-06T07:00:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T07:00:00Z", + "deliveryEnd": "2025-07-06T08:00:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T08:00:00Z", + "deliveryEnd": "2025-07-06T09:00:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T09:00:00Z", + "deliveryEnd": "2025-07-06T10:00:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T10:00:00Z", + "deliveryEnd": "2025-07-06T11:00:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T11:00:00Z", + "deliveryEnd": "2025-07-06T12:00:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T12:00:00Z", + "deliveryEnd": "2025-07-06T13:00:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T13:00:00Z", + "deliveryEnd": "2025-07-06T14:00:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T14:00:00Z", + "deliveryEnd": "2025-07-06T15:00:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T15:00:00Z", + "deliveryEnd": "2025-07-06T16:00:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T16:00:00Z", + "deliveryEnd": "2025-07-06T17:00:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T17:00:00Z", + "deliveryEnd": "2025-07-06T18:00:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T18:00:00Z", + "deliveryEnd": "2025-07-06T19:00:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T19:00:00Z", + "deliveryEnd": "2025-07-06T20:00:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T20:00:00Z", + "deliveryEnd": "2025-07-06T21:00:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T21:00:00Z", + "deliveryEnd": "2025-07-06T22:00:00Z", + "entryPerArea": { + "SE3": 396.38 + } + } + ] +} diff --git a/tests/components/nordpool/snapshots/test_services.ambr b/tests/components/nordpool/snapshots/test_services.ambr index b271b433061..5e39082f647 100644 --- a/tests/components/nordpool/snapshots/test_services.ambr +++ b/tests/components/nordpool/snapshots/test_services.ambr @@ -131,3 +131,615 @@ ]), }) # --- +# name: test_service_call_for_price_indices[get_price_indices_for_date_15] + dict({ + 'SE3': list([ + dict({ + 'end': '2025-07-05T22:15:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:00:00+00:00', + }), + dict({ + 'end': '2025-07-05T22:30:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:15:00+00:00', + }), + dict({ + 'end': '2025-07-05T22:45:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:30:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:00:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:45:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:15:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:00:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:30:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:15:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:45:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:00:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:15:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:30:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:45:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:00:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:15:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:30:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:45:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:00:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:15:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:30:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:45:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:00:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:15:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:30:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:45:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:00:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:15:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:30:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:45:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:00:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:15:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:30:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:45:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:00:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:15:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:30:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:45:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:00:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:15:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:30:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:45:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:00:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:15:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:30:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:45:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:00:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:15:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:30:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:45:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:00:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:15:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:30:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:45:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:00:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:15:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:30:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:45:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:00:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:15:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:30:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:45:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:00:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:15:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:30:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:45:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:00:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:15:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:30:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:45:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:00:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:15:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:30:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:45:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:00:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:15:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:30:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:45:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:00:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:15:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:30:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:45:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:00:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:15:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:30:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:45:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:00:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:15:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:30:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:45:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:00:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:15:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:30:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:45:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:00:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:15:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:30:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:45:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T22:00:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:45:00+00:00', + }), + ]), + }) +# --- +# name: test_service_call_for_price_indices[get_price_indices_for_date_60] + dict({ + 'SE3': list([ + dict({ + 'end': '2025-07-05T23:00:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:00:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:00:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:00:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:00:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:00:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:00:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:00:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:00:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:00:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:00:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:00:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:00:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:00:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:00:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:00:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:00:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:00:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:00:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:00:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:00:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:00:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:00:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T22:00:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 71c4644ea95..c2d18c4702a 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import patch +import aiohttp from freezegun.api import FrozenDateTimeFactory from pynordpool import ( NordPoolAuthenticationError, @@ -90,6 +91,36 @@ async def test_coordinator( assert state.state == STATE_UNAVAILABLE assert "Empty response" in caplog.text + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=aiohttp.ClientError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=TimeoutError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -109,4 +140,4 @@ async def test_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81645" + assert state.state == "1.81983" diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py index d59ec4712d7..1042783fee8 100644 --- a/tests/components/nordpool/test_services.py +++ b/tests/components/nordpool/test_services.py @@ -1,8 +1,10 @@ """Test services in Nord Pool.""" +import json from unittest.mock import patch from pynordpool import ( + API, NordPoolAuthenticationError, NordPoolEmptyResponseError, NordPoolError, @@ -15,13 +17,16 @@ from homeassistant.components.nordpool.services import ( ATTR_AREAS, ATTR_CONFIG_ENTRY, ATTR_CURRENCY, + ATTR_RESOLUTION, + SERVICE_GET_PRICE_INDICES_FOR_DATE, SERVICE_GET_PRICES_FOR_DATE, ) from homeassistant.const import ATTR_DATE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker TEST_SERVICE_DATA = { ATTR_CONFIG_ENTRY: "to_replace", @@ -33,6 +38,20 @@ TEST_SERVICE_DATA_USE_DEFAULTS = { ATTR_CONFIG_ENTRY: "to_replace", ATTR_DATE: "2024-11-05", } +TEST_SERVICE_INDICES_DATA_60 = { + ATTR_CONFIG_ENTRY: "to_replace", + ATTR_DATE: "2025-07-06", + ATTR_AREAS: "SE3", + ATTR_CURRENCY: "SEK", + ATTR_RESOLUTION: 60, +} +TEST_SERVICE_INDICES_DATA_15 = { + ATTR_CONFIG_ENTRY: "to_replace", + ATTR_DATE: "2025-07-06", + ATTR_AREAS: "SE3", + ATTR_CURRENCY: "SEK", + ATTR_RESOLUTION: 15, +} @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") @@ -163,3 +182,66 @@ async def test_service_call_config_entry_bad_state( return_response=True, ) assert err.value.translation_key == "entry_not_loaded" + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_service_call_for_price_indices( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test get_price_indices_for_date service call.""" + + fixture_60 = json.loads(await async_load_fixture(hass, "indices_60.json", DOMAIN)) + fixture_15 = json.loads(await async_load_fixture(hass, "indices_15.json", DOMAIN)) + + aioclient_mock.request( + "GET", + url=API + "/DayAheadPriceIndices", + params={ + "date": "2025-07-06", + "market": "DayAhead", + "indexNames": "SE3", + "currency": "SEK", + "resolutionInMinutes": "60", + }, + json=fixture_60, + ) + + aioclient_mock.request( + "GET", + url=API + "/DayAheadPriceIndices", + params={ + "date": "2025-07-06", + "market": "DayAhead", + "indexNames": "SE3", + "currency": "SEK", + "resolutionInMinutes": "15", + }, + json=fixture_15, + ) + + service_data = TEST_SERVICE_INDICES_DATA_60.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICE_INDICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + + assert response == snapshot(name="get_price_indices_for_date_60") + + service_data = TEST_SERVICE_INDICES_DATA_15.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICE_INDICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + + assert response == snapshot(name="get_price_indices_for_date_15") diff --git a/tests/components/nuki/__init__.py b/tests/components/nuki/__init__.py index 4f5728003fc..307ff080d71 100644 --- a/tests/components/nuki/__init__.py +++ b/tests/components/nuki/__init__.py @@ -14,28 +14,33 @@ from tests.common import ( ) -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, mock_nuki_requests: requests_mock.Mocker +) -> MockConfigEntry: """Mock integration setup.""" - with requests_mock.Mocker() as mock: - # Mocking authentication endpoint - mock.get("http://1.1.1.1:8080/info", json=MOCK_INFO) - mock.get( - "http://1.1.1.1:8080/list", - json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), - ) - mock.get( - "http://1.1.1.1:8080/callback/list", - json=await async_load_json_object_fixture( - hass, "callback_list.json", DOMAIN - ), - ) - mock.get( - "http://1.1.1.1:8080/callback/add", - json=await async_load_json_object_fixture( - hass, "callback_add.json", DOMAIN - ), - ) - entry = await setup_nuki_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + # Mocking authentication endpoint + mock_nuki_requests.get("http://1.1.1.1:8080/info", json=MOCK_INFO) + mock_nuki_requests.get( + "http://1.1.1.1:8080/list", + json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), + ) + callback_list_data = await async_load_json_object_fixture( + hass, "callback_list.json", DOMAIN + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/list", + json=callback_list_data, + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/add", + json=await async_load_json_object_fixture(hass, "callback_add.json", DOMAIN), + ) + # Mock the callback remove endpoint for teardown + mock_nuki_requests.delete( + requests_mock.ANY, + json={"success": True}, + ) + entry = await setup_nuki_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/nuki/conftest.py b/tests/components/nuki/conftest.py new file mode 100644 index 00000000000..624a5cafb9e --- /dev/null +++ b/tests/components/nuki/conftest.py @@ -0,0 +1,13 @@ +"""Fixtures for nuki tests.""" + +from collections.abc import Generator + +import pytest +import requests_mock + + +@pytest.fixture +def mock_nuki_requests() -> Generator[requests_mock.Mocker]: + """Mock nuki HTTP requests.""" + with requests_mock.Mocker() as mock: + yield mock diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py index 11507100aae..20551a66307 100644 --- a/tests/components/nuki/test_binary_sensor.py +++ b/tests/components/nuki/test_binary_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -19,9 +20,10 @@ async def test_binary_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test binary sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.BINARY_SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py index fc2d9d1cba8..6d8c3cc43fc 100644 --- a/tests/components/nuki/test_lock.py +++ b/tests/components/nuki/test_lock.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_locks( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test locks.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.LOCK]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py index 69a0aec56f7..d03fe7f0da6 100644 --- a/tests/components/nuki/test_sensor.py +++ b/tests/components/nuki/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 4ccf8f69c42..b5e5e18f664 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.components.number import ( + AMBIGUOUS_UNITS, ATTR_MAX, ATTR_MIN, ATTR_MODE, @@ -48,6 +49,7 @@ from . import common from tests.common import ( MockConfigEntry, + MockEntity, MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, @@ -61,6 +63,25 @@ from tests.common import ( TEST_DOMAIN = "test" +class MockNumber(MockEntity, NumberEntity): + """Mock NumberEntity class to test unit of measurement.""" + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._handle("device_class") + + @property + def native_unit_of_measurement(self): + """Return the native unit_of_measurement of this sensor.""" + return self._handle("native_unit_of_measurement") + + @property + def native_value(self): + """Return the native value of this sensor.""" + return self._handle("native_value") + + class MockDefaultNumberEntity(NumberEntity): """Mock NumberEntity device to use in tests. @@ -900,6 +921,33 @@ async def test_translated_unit_with_native_unit_raises( assert entity0.entity_id is None +@pytest.mark.parametrize( + ("ambiguous_unit", "normalized_unit"), + [ + (ambiguous_unit, normalized_unit) + for ambiguous_unit, normalized_unit in AMBIGUOUS_UNITS.items() + ], +) +async def test_ambiguous_unit_of_measurement_compat( + hass: HomeAssistant, ambiguous_unit: str, normalized_unit: str +) -> None: + """Test ambiguous native_unit_of_measurement values are corrected.""" + entity0 = MockNumber( + name="Test", + native_value="0.0", + native_unit_of_measurement=ambiguous_unit, + ) + setup_test_component_platform(hass, DOMAIN, [entity0]) + + assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + await hass.async_block_till_done() + + # Check compatible unit is applied + state = hass.states.get(entity0.entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == normalized_unit + + def test_device_classes_aligned() -> None: """Make sure all sensor device classes are also available in NumberDeviceClass.""" diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 6f1fb94478d..18c038c17a0 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import area_registry as ar, device_registry as dr from homeassistant.setup import async_setup_component from .util import _get_mock_nutclient, async_init_integration @@ -247,6 +247,7 @@ async def test_serial_number( async def test_device_location( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test for suggested location on device.""" @@ -269,7 +270,10 @@ async def test_device_location( ) assert device_entry is not None - assert device_entry.suggested_area == mock_device_location + assert ( + device_entry.area_id + == area_registry.async_get_area_by_name(mock_device_location).id + ) async def test_update_options(hass: HomeAssistant) -> None: diff --git a/tests/components/nyt_games/fixtures/latest.json b/tests/components/nyt_games/fixtures/latest.json index 73a6f440fc0..16601243052 100644 --- a/tests/components/nyt_games/fixtures/latest.json +++ b/tests/components/nyt_games/fixtures/latest.json @@ -25,43 +25,46 @@ }, "wordle": { "legacyStats": { - "gamesPlayed": 70, - "gamesWon": 51, + "gamesPlayed": 1111, + "gamesWon": 1069, "guesses": { "1": 0, - "2": 1, - "3": 7, - "4": 11, - "5": 20, - "6": 12, - "fail": 19 + "2": 8, + "3": 83, + "4": 440, + "5": 372, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonDayOffset": 1189, + "currentStreak": 229, + "maxStreak": 229, + "lastWonDayOffset": 1472, "hasPlayed": true, - "autoOptInTimestamp": 1708273168957, - "hasMadeStatsChoice": false, - "timestamp": 1726831978 + "autoOptInTimestamp": 1712205417018, + "hasMadeStatsChoice": true, + "timestamp": 1751255756 }, "calculatedStats": { - "gamesPlayed": 33, - "gamesWon": 26, + "currentStreak": 237, + "maxStreak": 241, + "lastWonPrintDate": "2025-07-08", + "lastCompletedPrintDate": "2025-07-08", + "hasPlayed": true + }, + "totalStats": { + "gamesWon": 1077, + "gamesPlayed": 1119, "guesses": { "1": 0, - "2": 1, - "3": 4, - "4": 7, - "5": 10, - "6": 4, - "fail": 7 + "2": 8, + "3": 83, + "4": 444, + "5": 376, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonPrintDate": "2024-09-20", - "lastCompletedPrintDate": "2024-09-20", "hasPlayed": true, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/fixtures/new_account.json b/tests/components/nyt_games/fixtures/new_account.json index ad4d8e2e416..d35ce4cdebc 100644 --- a/tests/components/nyt_games/fixtures/new_account.json +++ b/tests/components/nyt_games/fixtures/new_account.json @@ -7,26 +7,6 @@ "stats": { "wordle": { "legacyStats": { - "gamesPlayed": 1, - "gamesWon": 1, - "guesses": { - "1": 0, - "2": 0, - "3": 0, - "4": 0, - "5": 1, - "6": 0, - "fail": 0 - }, - "currentStreak": 0, - "maxStreak": 1, - "lastWonDayOffset": 1118, - "hasPlayed": true, - "autoOptInTimestamp": 1727357874700, - "hasMadeStatsChoice": false, - "timestamp": 1727358123 - }, - "calculatedStats": { "gamesPlayed": 0, "gamesWon": 0, "guesses": { @@ -38,12 +18,35 @@ "6": 0, "fail": 0 }, + "currentStreak": 0, + "maxStreak": 1, + "lastWonDayOffset": 1118, + "hasPlayed": true, + "autoOptInTimestamp": 1727357874700, + "hasMadeStatsChoice": false, + "timestamp": 1727358123 + }, + "calculatedStats": { "currentStreak": 0, "maxStreak": 1, "lastWonPrintDate": "", "lastCompletedPrintDate": "", + "hasPlayed": false + }, + "totalStats": { + "gamesPlayed": 1, + "gamesWon": 1, + "guesses": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 1, + "6": 0, + "fail": 0 + }, "hasPlayed": false, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index d9ce6f15a4d..f920b064f0b 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '218886794_connections', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'New York Times', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -50,7 +48,6 @@ '218886794_spelling_bee', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'New York Times', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -83,7 +79,6 @@ '218886794_wordle', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'New York Times', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 5a1aa384f0f..10fddcfa365 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -473,7 +473,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '237', }) # --- # name: test_all_entities[sensor.wordle_highest_streak-entry] @@ -529,7 +529,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '241', }) # --- # name: test_all_entities[sensor.wordle_played-entry] @@ -581,7 +581,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '70', + 'state': '1119', }) # --- # name: test_all_entities[sensor.wordle_won-entry] @@ -633,6 +633,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '51', + 'state': '1077', }) # --- diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index dd3eda0e81f..3755b84a6f9 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -21,7 +21,21 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry DEFAULT_JOB = { - "job": {}, + "job": { + "averagePrintTime": None, + "estimatedPrintTime": None, + "filament": None, + "file": { + "date": None, + "display": None, + "name": None, + "origin": None, + "path": None, + "size": None, + }, + "lastPrintTime": None, + "user": None, + }, "progress": {"completion": 50}, } diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 8c1c0a7712e..3b0ed2ded0b 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime from freezegun.api import FrozenDateTimeFactory +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,11 +24,7 @@ async def test_sensors( }, "temperature": {"tool1": {"actual": 18.83136, "target": 37.83136}}, } - job = { - "job": {}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Printing", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) await init_integration(hass, "sensor", printer=printer, job=job) @@ -80,6 +77,21 @@ async def test_sensors( entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == "Test_File_Name.gcode" + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == "123.456789" + assert state.attributes.get("unit_of_measurement") == UnitOfInformation.MEGABYTES + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_no_target_temp( hass: HomeAssistant, @@ -106,11 +118,25 @@ async def test_sensors_no_target_temp( state = hass.states.get("sensor.octoprint_target_tool1_temp") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint target tool1 temp" entry = entity_registry.async_get("sensor.octoprint_target_tool1_temp") assert entry.unique_id == "target tool1 temp-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_paused( hass: HomeAssistant, @@ -125,24 +151,20 @@ async def test_sensors_paused( }, "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } - job = { - "job": {}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=printer, job=job) state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" @@ -154,11 +176,7 @@ async def test_sensors_printer_disconnected( entity_registry: er.EntityRegistry, ) -> None: """Test the underlying sensors.""" - job = { - "job": {}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=None, job=job) @@ -171,21 +189,43 @@ async def test_sensors_printer_disconnected( state = hass.states.get("sensor.octoprint_current_state") assert state is not None - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE assert state.name == "OctoPrint Current State" entry = entity_registry.async_get("sensor.octoprint_current_state") assert entry.unique_id == "Current State-uuid" state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + + +def __standard_job(): + return { + "job": { + "averagePrintTime": 6500, + "estimatedPrintTime": 6000, + "filament": {"tool0": {"length": 3000, "volume": 7}}, + "file": { + "date": 1577836800, + "display": "Test File Name", + "name": "Test_File_Name.gcode", + "origin": "local", + "path": "Folder1/Folder2/Test_File_Name.gcode", + "size": 123456789, + }, + "lastPrintTime": 12345.678, + "user": "testUser", + }, + "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, + "state": "Printing", + } diff --git a/tests/components/octoprint/test_servics.py b/tests/components/octoprint/test_services.py similarity index 100% rename from tests/components/octoprint/test_servics.py rename to tests/components/octoprint/test_services.py diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index ccf09f546cf..dc49f5f4042 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'chargerid', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ohme', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'chargerid', - 'suggested_area': None, 'sw_version': 'v2.65', 'via_device_id': None, }) diff --git a/tests/components/ollama/__init__.py b/tests/components/ollama/__init__.py index 6ad77bb2217..9e7ae4772d4 100644 --- a/tests/components/ollama/__init__.py +++ b/tests/components/ollama/__init__.py @@ -5,10 +5,15 @@ from homeassistant.helpers import llm TEST_USER_DATA = { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: "test model", } TEST_OPTIONS = { ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, ollama.CONF_MAX_HISTORY: 2, + ollama.CONF_MODEL: "test_model:latest", +} + +TEST_AI_TASK_OPTIONS = { + ollama.CONF_MAX_HISTORY: 2, + ollama.CONF_MODEL: "test_model:latest", } diff --git a/tests/components/ollama/conftest.py b/tests/components/ollama/conftest.py index 7658d1cbfab..f3406bf5566 100644 --- a/tests/components/ollama/conftest.py +++ b/tests/components/ollama/conftest.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component -from . import TEST_OPTIONS, TEST_USER_DATA +from . import TEST_AI_TASK_OPTIONS, TEST_OPTIONS, TEST_USER_DATA from tests.common import MockConfigEntry @@ -30,7 +30,22 @@ def mock_config_entry( entry = MockConfigEntry( domain=ollama.DOMAIN, data=TEST_USER_DATA, - options=mock_config_entry_options, + version=3, + minor_version=2, + subentries_data=[ + { + "data": {**TEST_OPTIONS, **mock_config_entry_options}, + "subentry_type": "conversation", + "title": "Ollama Conversation", + "unique_id": None, + }, + { + "data": TEST_AI_TASK_OPTIONS, + "subentry_type": "ai_task_data", + "title": "Ollama AI Task", + "unique_id": None, + }, + ], ) entry.add_to_hass(hass) return entry @@ -41,8 +56,14 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + **subentry.data, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + }, ) return mock_config_entry diff --git a/tests/components/ollama/test_ai_task.py b/tests/components/ollama/test_ai_task.py new file mode 100644 index 00000000000..cb639db0f8e --- /dev/null +++ b/tests/components/ollama/test_ai_task.py @@ -0,0 +1,359 @@ +"""Test AI Task platform of Ollama integration.""" + +from pathlib import Path +from unittest.mock import patch + +import ollama +import pytest +import voluptuous as vol + +from homeassistant.components import ai_task, media_source +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.ollama_ai_task" + + # Ensure entity is linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "Generated test data" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task_with_streaming( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with streaming response.""" + entity_id = "ai_task.ollama_ai_task" + + async def mock_stream(): + """Mock streaming response.""" + yield {"message": {"role": "assistant", "content": "Stream "}} + yield { + "message": {"role": "assistant", "content": "response"}, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_stream(), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Streaming Task", + entity_id=entity_id, + instructions="Generate streaming data", + ) + + assert result.data == "Stream response" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with connection error.""" + entity_id = "ai_task.ollama_ai_task" + + with ( + patch( + "ollama.AsyncClient.chat", + side_effect=Exception("Connection failed"), + ), + pytest.raises(Exception, match="Connection failed"), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Error Task", + entity_id=entity_id, + instructions="Generate data that will fail", + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task_empty_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with empty response.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock response with space (minimally non-empty) + async def mock_minimal_response(): + """Mock minimal streaming response.""" + yield { + "message": {"role": "assistant", "content": " "}, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_minimal_response(), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Minimal Task", + entity_id=entity_id, + instructions="Generate minimal data", + ) + + assert result.data == " " + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": { + "role": "assistant", + "content": '{"characters": ["Mario", "Luigi"]}', + }, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ) as mock_chat: + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + assert result.data == {"characters": ["Mario", "Luigi"]} + + assert mock_chat.call_count == 1 + assert mock_chat.call_args[1]["format"] == { + "type": "object", + "properties": {"characters": {"items": {"type": "string"}, "type": "array"}}, + "required": ["characters"], + } + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": { + "role": "assistant", + "content": "INVALID JSON RESPONSE", + }, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ), + pytest.raises(HomeAssistantError), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachment( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ) as mock_chat, + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + assert result.data == "Generated test data" + + assert mock_chat.call_count == 1 + messages = mock_chat.call_args[1]["messages"] + assert len(messages) == 2 + chat_message = messages[1] + assert chat_message.role == "user" + assert chat_message.content == "Generate test data" + assert chat_message.images == [ + ollama.Image(value=Path("doorbell_snapshot.jpg")), + ] + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_unsupported_file_format( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ), + pytest.raises( + HomeAssistantError, + match="Ollama only supports image attachments in user content", + ), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 34282f25e90..1a873c2adb7 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import ollama +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,7 +18,7 @@ TEST_MODEL = "test_model:latest" async def test_form(hass: HomeAssistant) -> None: - """Test flow when the model is already downloaded.""" + """Test flow when configuring URL only.""" # Pretend we already set up a config entry. hass.config.components.add(ollama.DOMAIN) MockConfigEntry( @@ -34,7 +35,6 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # test model is already "downloaded" return_value={"models": [{"model": TEST_MODEL}]}, ), patch( @@ -42,143 +42,269 @@ async def test_form(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - # Step 1: URL result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} ) await hass.async_block_till_done() - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["data"] == { + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, } + # No subentries created by default + assert len(result2.get("subentries", [])) == 0 assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_need_download(hass: HomeAssistant) -> None: - """Test flow when a model needs to be downloaded.""" - # Pretend we already set up a config entry. - hass.config.components.add(ollama.DOMAIN) +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we abort on duplicate config entry.""" MockConfigEntry( domain=ollama.DOMAIN, - state=config_entries.ConfigEntryState.LOADED, + data={ + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model", + }, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] - pull_ready = asyncio.Event() - pull_called = asyncio.Event() - pull_model: str | None = None + with patch( + "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", + return_value={"models": [{"model": "test_model"}]}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + ollama.CONF_URL: "http://localhost:11434", + }, + ) - async def pull(self, model: str, *args, **kwargs) -> None: - nonlocal pull_model + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" - async with asyncio.timeout(1): - await pull_ready.wait() - pull_model = model - pull_called.set() +async def test_subentry_options( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the subentry options form.""" + subentry = next(iter(mock_config_entry.subentries.values())) + + # Test reconfiguration + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + assert options_flow["type"] is FlowResultType.FORM + assert options_flow["step_id"] == "set_options" + + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 100, + ollama.CONF_NUM_CTX: 32768, + ollama.CONF_THINK: True, + }, + ) + await hass.async_block_till_done() + + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 100.0, + ollama.CONF_NUM_CTX: 32768.0, + ollama.CONF_THINK: True, + } + + +async def test_creating_new_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating a new conversation subentry includes name field.""" + # Start a new subentry flow + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with name field + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, + } + + +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_subentry_need_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when model needs to be downloaded.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop 1 iteration with ( patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # No models are downloaded - return_value={}, + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - pull, - ), - patch( - "homeassistant.components.ollama.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + patch("ollama.AsyncClient.pull", delayed_pull), ): - # Step 1: URL - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, ) + + assert new_flow["type"] is FlowResultType.FORM, new_flow + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", # not cached + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + await hass.async_block_till_done() - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - # Step 3: download - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - ) - await hass.async_block_till_done() - - # Run again without the task finishing. - # We should still be downloading. - assert result4["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - ) - await hass.async_block_till_done() - assert result4["type"] is FlowResultType.SHOW_PROGRESS - - # Signal fake pull method to complete - pull_ready.set() - async with asyncio.timeout(1): - await pull_called.wait() - - assert pull_model == TEST_MODEL - - # Step 4: finish - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} ) - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["data"] == { - ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( - hass: HomeAssistant, mock_config_entry, mock_init_component +async def test_subentry_download_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the options form.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, - ollama.CONF_THINK: True, - }, - ) - await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == { - ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, - ollama.CONF_THINK: True, - } + """Test subentry creation when model download fails.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + await asyncio.sleep(0) # yield + + raise RuntimeError("Download failed") + + with ( + patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ), + patch("ollama.AsyncClient.pull", delayed_pull), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model that needs downloading but will fail + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + # Should show progress flow result for download + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + + # Wait for download task to complete (with error) + await hass.async_block_till_done() + + # Submit the progress flow - should get failure + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "download_failed" @pytest.mark.parametrize( @@ -206,40 +332,207 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["errors"] == {"base": error} -async def test_download_error(hass: HomeAssistant) -> None: - """Test we handle errors while downloading a model.""" +async def test_form_invalid_url(hass: HomeAssistant) -> None: + """Test we handle invalid URL.""" result = await hass.config_entries.flow.async_init( ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - async def _delayed_runtime_error(*args, **kwargs): - await asyncio.sleep(0) - raise RuntimeError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {ollama.CONF_URL: "not-a-valid-url"} + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_url"} + + +async def test_subentry_connection_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when connection to Ollama server fails.""" + with patch( + "ollama.AsyncClient.list", + side_effect=ConnectError("Connection failed"), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.ABORT + assert new_flow["reason"] == "cannot_connect" + + +async def test_subentry_model_check_exception( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when checking model availability throws exception.""" + with patch( + "ollama.AsyncClient.list", + side_effect=[ + {"models": [{"model": TEST_MODEL}]}, # First call succeeds + RuntimeError("Failed to check models"), # Second call fails + ], + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model, should fail when checking availability + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "new_model:latest", + CONF_NAME: "Test Conversation", + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_subentry_reconfigure_with_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry when model needs to be downloaded.""" + subentry = next(iter(mock_config_entry.subentries.values())) + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop with ( patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - return_value={}, - ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - _delayed_runtime_error, + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, ), + patch("ollama.AsyncClient.pull", delayed_pull), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} + reconfigure_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "set_options" + + # Reconfigure with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75, + ollama.CONF_NUM_CTX: 8192, + ollama.CONF_THINK: True, + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + + await hass.async_block_till_done() + + # Finish download + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert subentry.data == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75.0, + ollama.CONF_NUM_CTX: 8192.0, + ollama.CONF_THINK: True, + } + + +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + original ai_task + assert len(mock_config_entry.subentries) == 2 + + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": "test_model:latest"}]}, + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "set_options" + assert not result.get("errors") + + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": "test_model:latest"}]}, + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom AI Task", + ollama.CONF_MODEL: "test_model:latest", + ollama.CONF_MAX_HISTORY: 5, + ollama.CONF_NUM_CTX: 4096, + ollama.CONF_KEEP_ALIVE: 30, + ollama.CONF_THINK: False, + }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Custom AI Task" + assert result2.get("data") == { + ollama.CONF_MODEL: "test_model:latest", + ollama.CONF_MAX_HISTORY: 5, + ollama.CONF_NUM_CTX: 4096, + ollama.CONF_KEEP_ALIVE: 30, + ollama.CONF_THINK: False, + } - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure(result3["flow_id"]) - await hass.async_block_till_done() + assert ( + len(mock_config_entry.subentries) == 3 + ) # Original conversation + original ai_task + new ai_task - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "download_failed" + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.title == "Custom AI Task" + + +async def test_ai_task_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index e83c2a3495f..4904829a31c 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -15,7 +15,12 @@ from homeassistant.components.conversation import trace from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent, + llm, +) from tests.common import MockConfigEntry @@ -35,7 +40,7 @@ async def stream_generator(response: dict | list[dict]) -> AsyncGenerator[dict]: yield msg -@pytest.mark.parametrize("agent_id", [None, "conversation.mock_title"]) +@pytest.mark.parametrize("agent_id", [None, "conversation.ollama_conversation"]) async def test_chat( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -68,7 +73,7 @@ async def test_chat( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -128,7 +133,7 @@ async def test_chat_stream( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -149,13 +154,16 @@ async def test_template_variables( mock_user.id = "12345" mock_user.name = "Test User" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": ( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." ), + ollama.CONF_MODEL: "test_model:latest", }, ) with ( @@ -204,7 +212,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -291,7 +299,7 @@ async def test_function_call( ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -382,10 +390,12 @@ async def test_unknown_hass_api( mock_init_component, ) -> None: """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "non-existing", }, ) @@ -518,8 +528,11 @@ async def test_message_history_unlimited( with ( patch("ollama.AsyncClient.chat", side_effect=stream) as mock_chat, ): - hass.config_entries.async_update_entry( - mock_config_entry, options={ollama.CONF_MAX_HISTORY: 0} + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, ollama.CONF_MAX_HISTORY: 0}, ) for i in range(100): result = await conversation.async_converse( @@ -563,9 +576,12 @@ async def test_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that template error handling works.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ + **subentry.data, "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -586,6 +602,8 @@ async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test OllamaConversationEntity.""" agent = conversation.get_agent_manager(hass).async_get_agent( @@ -593,10 +611,27 @@ async def test_conversation_agent( ) assert agent.supported_languages == MATCH_ALL - state = hass.states.get("conversation.mock_title") + state = hass.states.get("conversation.ollama_conversation") assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + entity_entry = entity_registry.async_get("conversation.ollama_conversation") + assert entity_entry + subentry = mock_config_entry.subentries.get(entity_entry.unique_id) + assert subentry + + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry + + assert device_entry.identifiers == {(ollama.DOMAIN, subentry.subentry_id)} + assert device_entry.name == subentry.title + assert device_entry.manufacturer == "Ollama" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + + model, _, version = subentry.data[ollama.CONF_MODEL].partition(":") + assert device_entry.model == model + assert device_entry.sw_version == version + async def test_conversation_agent_with_assist( hass: HomeAssistant, @@ -609,7 +644,7 @@ async def test_conversation_agent_with_assist( ) assert agent.supported_languages == MATCH_ALL - state = hass.states.get("conversation.mock_title") + state = hass.states.get("conversation.ollama_conversation") assert state assert ( state.attributes[ATTR_SUPPORTED_FEATURES] @@ -642,7 +677,7 @@ async def test_options( "test message", None, Context(), - agent_id="conversation.mock_title", + agent_id="conversation.ollama_conversation", ) assert mock_chat.call_count == 1 @@ -667,9 +702,12 @@ async def test_reasoning_filter( entry = MockConfigEntry() entry.add_to_hass(hass) - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ + **subentry.data, ollama.CONF_THINK: think, }, ) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index d1074226837..766de8a7d6d 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -1,16 +1,38 @@ """Tests for the Ollama integration.""" +from typing import Any from unittest.mock import patch from httpx import ConnectError import pytest from homeassistant.components import ollama +from homeassistant.components.ollama.const import DOMAIN +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er, llm +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component +from . import TEST_OPTIONS + from tests.common import MockConfigEntry +V1_TEST_USER_DATA = { + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", +} + +V1_TEST_OPTIONS = { + ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + ollama.CONF_MAX_HISTORY: 2, +} + +V21_TEST_USER_DATA = V1_TEST_USER_DATA +V21_TEST_OPTIONS = V1_TEST_OPTIONS + @pytest.mark.parametrize( ("side_effect", "error"), @@ -34,3 +56,932 @@ async def test_init_error( assert await async_setup_component(hass, ollama.DOMAIN, {}) await hass.async_block_till_done() assert error in caplog.text + + +async def test_migration_from_v1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1.""" + # Create a v1 config entry with conversation options and an entity + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=V1_TEST_USER_DATA, + options=V1_TEST_OPTIONS, + version=1, + title="llama-3.2-8b", + ) + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="llama_3_2_8b", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 3 + # After migration, parent entry should only have URL + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} + assert mock_config_entry.options == {} + + assert len(mock_config_entry.subentries) == 2 + + subentry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "conversation" + ) + ) + assert subentry.unique_id is None + assert subentry.title == "llama-3.2-8b" + assert subentry.subentry_type == "conversation" + # Subentry should now include the model from the original options + expected_subentry_data = TEST_OPTIONS.copy() + assert subentry.data == expected_subentry_data + + # Find the AI Task subentry + ai_task_subentry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert ai_task_subentry.unique_id is None + assert ai_task_subentry.title == "Ollama AI Task" + assert ai_task_subentry.subentry_type == "ai_task_data" + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.unique_id == subentry.subentry_id + + # Check device migration + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + migrated_device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +async def test_migration_from_v1_with_multiple_urls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 with different URLs.""" + # Create two v1 config entries with different URLs + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama 1", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11435", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama 1", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="ollama_1", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama 2", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for idx, entry in enumerate(entries): + assert entry.version == 3 + assert entry.minor_version == 3 + assert not entry.options + assert len(entry.subentries) == 2 + + subentry = next( + iter( + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ) + ) + assert subentry.subentry_type == "conversation" + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data + assert subentry.title == f"Ollama {idx + 1}" + + # Find the AI Task subentry + ai_task_subentry = next( + iter( + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ) + ) + assert ai_task_subentry.subentry_type == "ai_task_data" + assert ai_task_subentry.title == "Ollama AI Task" + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + ) + assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} + + +async def test_migration_from_v1_with_same_urls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 with same URLs consolidates entries.""" + # Create two v1 config entries with the same URL + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, # Same URL + options=V1_TEST_OPTIONS, + version=1, + title="Ollama 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="ollama", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should have only one entry left (consolidated) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.version == 3 + assert entry.minor_version == 3 + assert not entry.options + # Two conversation subentries from the two original entries and 1 aitask subentry + assert len(entry.subentries) == 3 + + # Check both subentries exist with correct data + subentries = list(entry.subentries.values()) + titles = [sub.title for sub in subentries] + assert "Ollama" in titles + assert "Ollama 2" in titles + + conversation_subentries = [ + subentry for subentry in subentries if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data + + # Check devices were migrated correctly + dev = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="ollama", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 3 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == {"model": "llama3.2:latest", **V1_TEST_OPTIONS} + assert "Ollama" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == {"model": "llama3.2:latest"} + assert ai_task_subentries[0].title == "Ollama AI Task" + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + +async def test_migration_from_v2_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=V21_TEST_USER_DATA, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=V21_TEST_OPTIONS, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Ollama", + unique_id=None, + ), + ConfigSubentryData( + data=V21_TEST_OPTIONS, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Ollama 2", + unique_id=None, + ), + ], + title="Ollama", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Ollama", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="ollama", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Ollama 2", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 3 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + # Since TEST_USER_DATA no longer has a model, subentry data should be TEST_OPTIONS + assert subentry.data == TEST_OPTIONS + assert "Ollama" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.ollama") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.ollama_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +async def test_migration_from_v2_2(hass: HomeAssistant) -> None: + """Test migration from version 2.2.""" + subentry_data = ConfigSubentryData( + data=V21_TEST_USER_DATA, + subentry_type="conversation", + title="Test Conversation", + unique_id=None, + ) + + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", # Model still in main data + }, + version=2, + minor_version=2, + subentries_data=[subentry_data], + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Check migration to v3.1 + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 3 + + # Check that model was moved from main data to subentry + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} + assert len(mock_config_entry.subentries) == 2 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.data == { + **V21_TEST_USER_DATA, + ollama.CONF_MODEL: "test_model:latest", + } + + +async def test_migration_from_v3_1_without_subentry(hass: HomeAssistant) -> None: + """Test migration from version 3.1 where there is no existing subentry. + + This exercises the code path where the model is not moved to a subentry + because the subentry does not exist, which is a scenario that can happen + if the user created the config entry without adding a subentry, or + if the user manually removed the subentry after the migration to v3.1. + """ + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + ollama.CONF_MODEL: "test_model:latest", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 3 + + assert next(iter(mock_config_entry.subentries.values()), None) is None + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v3_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 3.2.""" + # Create a v3.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://localhost:11434"}, + disabled_by=config_entry_disabled_by, + version=3, + minor_version=2, + subentries_data=[ + { + "data": V1_TEST_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Ollama", + "unique_id": None, + }, + { + "data": {"model": "llama3.2:latest"}, + "subentry_type": "ai_task_data", + "title": "Ollama AI Task", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="ollama", + ) + + # Verify initial state + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 2 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 3 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index 07e56a78fae..c3d8d92a9d2 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'W1122333044455', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ondilo', @@ -26,8 +25,7 @@ 'name': 'Pool 1', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, + 'serial_number': 'W1122333044455', 'sw_version': '1.7.1-stable', 'via_device_id': None, }) @@ -50,7 +48,6 @@ 'W2233304445566', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ondilo', @@ -59,8 +56,7 @@ 'name': 'Pool 2', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, + 'serial_number': 'W2233304445566', 'sw_version': '1.7.1-stable', 'via_device_id': None, }) diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr index 9b2ed7e4d94..2573c34e1fa 100644 --- a/tests/components/onedrive/snapshots/test_init.ambr +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'mock_drive_id', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Microsoft', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 4d0abd5a602..40a8def0e39 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -21,7 +21,6 @@ from homeassistant.components.onedrive.backup import ( from homeassistant.components.onedrive.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -36,8 +35,7 @@ async def setup_backup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> AsyncGenerator[None]: - """Set up onedrive and backup integrations.""" - async_initialize_backup(hass) + """Set up onedrive integration.""" with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 370bcc871c6..32804bca28e 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -15,6 +15,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: { "/type": [b"DS2405"], "/PIO": [b" 1"], + "/sensed": [b" 1"], }, }, "10.111111111111": { diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 6309b80b28d..521e5c50925 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_binary_sensors[binary_sensor.05_111111111111_sensed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.05_111111111111_sensed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed', + 'platform': 'onewire', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensed', + 'unique_id': '/05.111111111111/sensed', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.05_111111111111_sensed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/sensed', + 'friendly_name': '05.111111111111 Sensed', + }), + 'context': , + 'entity_id': 'binary_sensor.05_111111111111_sensed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -39,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.A', 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_a', @@ -89,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.B', 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_b', @@ -139,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.0', 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_0', @@ -189,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.1', 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_1', @@ -239,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.2', 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_2', @@ -289,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.3', 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_3', @@ -339,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.4', 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_4', @@ -389,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.5', 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_5', @@ -439,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.6', 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_6', @@ -489,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.7', 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_7', @@ -539,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.A', 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', @@ -589,7 +627,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.B', 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', @@ -640,7 +677,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.0', 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', @@ -691,7 +727,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.1', 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', @@ -742,7 +777,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.2', 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', @@ -793,7 +827,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.3', 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 9b2a0e00a62..b879541d4ca 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '05.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -50,7 +48,6 @@ '10.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -83,7 +79,6 @@ '12.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -116,7 +110,6 @@ '1D.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -126,7 +119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': , }) @@ -149,7 +141,6 @@ '1F.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -159,7 +150,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -182,7 +172,6 @@ '20.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -192,7 +181,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -215,7 +203,6 @@ '22.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -225,7 +212,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -248,7 +234,6 @@ '26.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -258,7 +243,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -281,7 +265,6 @@ '28.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -291,7 +274,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -314,7 +296,6 @@ '28.222222222222', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -324,7 +305,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '222222222222', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -347,7 +327,6 @@ '28.222222222223', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -357,7 +336,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '222222222223', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -380,7 +358,6 @@ '29.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -390,7 +367,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -413,7 +389,6 @@ '30.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -423,7 +398,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -446,7 +420,6 @@ '3A.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -456,7 +429,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -479,7 +451,6 @@ '3B.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -489,7 +460,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -512,7 +482,6 @@ '42.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -522,7 +491,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -545,7 +513,6 @@ '7E.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Embedded Data Systems', @@ -555,7 +522,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -578,7 +544,6 @@ '7E.222222222222', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Embedded Data Systems', @@ -588,7 +553,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '222222222222', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -611,7 +575,6 @@ 'A6.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -621,7 +584,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -644,7 +606,6 @@ 'EF.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Hobby Boards', @@ -654,7 +615,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -677,7 +637,6 @@ 'EF.111111111112', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Hobby Boards', @@ -687,7 +646,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111112', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -710,7 +668,6 @@ 'EF.111111111113', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Hobby Boards', @@ -720,7 +677,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111113', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr index 9861a7d2f5e..d699f717fea 100644 --- a/tests/components/onewire/snapshots/test_select.ambr +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -52,7 +52,6 @@ '11', '12', ]), - 'raw_value': 12.0, }), 'context': , 'entity_id': 'select.28_111111111111_temperature_resolution', diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 8b49b7f3d5f..f19a168456d 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -45,7 +45,6 @@ 'device_class': 'temperature', 'device_file': '/10.111111111111/temperature', 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -103,7 +102,6 @@ 'device_class': 'pressure', 'device_file': '/12.111111111111/TAI8570/pressure', 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, 'state_class': , 'unit_of_measurement': , }), @@ -161,7 +159,6 @@ 'device_class': 'temperature', 'device_file': '/12.111111111111/TAI8570/temperature', 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -215,7 +212,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.A', 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, 'state_class': , }), 'context': , @@ -268,7 +264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.B', 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, 'state_class': , }), 'context': , @@ -325,7 +320,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.A', 'friendly_name': '20.111111111111 Latest voltage A', - 'raw_value': 1.11, 'state_class': , 'unit_of_measurement': , }), @@ -383,7 +377,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.B', 'friendly_name': '20.111111111111 Latest voltage B', - 'raw_value': 2.22, 'state_class': , 'unit_of_measurement': , }), @@ -441,7 +434,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.C', 'friendly_name': '20.111111111111 Latest voltage C', - 'raw_value': 3.33, 'state_class': , 'unit_of_measurement': , }), @@ -499,7 +491,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.D', 'friendly_name': '20.111111111111 Latest voltage D', - 'raw_value': 4.44, 'state_class': , 'unit_of_measurement': , }), @@ -557,7 +548,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.A', 'friendly_name': '20.111111111111 Voltage A', - 'raw_value': 1.1, 'state_class': , 'unit_of_measurement': , }), @@ -615,7 +605,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.B', 'friendly_name': '20.111111111111 Voltage B', - 'raw_value': 2.2, 'state_class': , 'unit_of_measurement': , }), @@ -673,7 +662,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.C', 'friendly_name': '20.111111111111 Voltage C', - 'raw_value': 3.3, 'state_class': , 'unit_of_measurement': , }), @@ -731,7 +719,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.D', 'friendly_name': '20.111111111111 Voltage D', - 'raw_value': 4.4, 'state_class': , 'unit_of_measurement': , }), @@ -789,7 +776,6 @@ 'device_class': 'temperature', 'device_file': '/22.111111111111/temperature', 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, 'state_class': , 'unit_of_measurement': , }), @@ -844,7 +830,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH3600/humidity', 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -899,7 +884,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH4000/humidity', 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -954,7 +938,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH5030/humidity', 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1009,7 +992,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HTM1735/humidity', 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -1064,7 +1046,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/humidity', 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1119,7 +1100,6 @@ 'device_class': 'illuminance', 'device_file': '/26.111111111111/S3-R1-A/illuminance', 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -1177,7 +1157,6 @@ 'device_class': 'pressure', 'device_file': '/26.111111111111/B1-R1-A/pressure', 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -1235,7 +1214,6 @@ 'device_class': 'temperature', 'device_file': '/26.111111111111/temperature', 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -1293,7 +1271,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VAD', 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1351,7 +1328,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VDD', 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -1409,7 +1385,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/vis', 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1467,7 +1442,6 @@ 'device_class': 'temperature', 'device_file': '/28.111111111111/temperature', 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1525,7 +1499,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222222/temperature9', 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1583,7 +1556,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222223/temperature', 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1641,7 +1613,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/temperature', 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1699,7 +1670,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/typeK/temperature', 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, 'state_class': , 'unit_of_measurement': , }), @@ -1757,7 +1727,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/vis', 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1815,7 +1784,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/volt', 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1873,7 +1841,6 @@ 'device_class': 'temperature', 'device_file': '/3B.111111111111/temperature', 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, 'state_class': , 'unit_of_measurement': , }), @@ -1931,7 +1898,6 @@ 'device_class': 'temperature', 'device_file': '/42.111111111111/temperature', 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, 'state_class': , 'unit_of_measurement': , }), @@ -1986,7 +1952,6 @@ 'device_class': 'humidity', 'device_file': '/7E.111111111111/EDS0068/humidity', 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, 'state_class': , 'unit_of_measurement': '%', }), @@ -2041,7 +2006,6 @@ 'device_class': 'illuminance', 'device_file': '/7E.111111111111/EDS0068/light', 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2099,7 +2063,6 @@ 'device_class': 'pressure', 'device_file': '/7E.111111111111/EDS0068/pressure', 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2157,7 +2120,6 @@ 'device_class': 'temperature', 'device_file': '/7E.111111111111/EDS0068/temperature', 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2215,7 +2177,6 @@ 'device_class': 'pressure', 'device_file': '/7E.222222222222/EDS0066/pressure', 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2273,7 +2234,6 @@ 'device_class': 'temperature', 'device_file': '/7E.222222222222/EDS0066/temperature', 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2328,7 +2288,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH3600/humidity', 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2383,7 +2342,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH4000/humidity', 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2438,7 +2396,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH5030/humidity', 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2493,7 +2450,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HTM1735/humidity', 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -2548,7 +2504,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/humidity', 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2603,7 +2558,6 @@ 'device_class': 'illuminance', 'device_file': '/A6.111111111111/S3-R1-A/illuminance', 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2661,7 +2615,6 @@ 'device_class': 'pressure', 'device_file': '/A6.111111111111/B1-R1-A/pressure', 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -2719,7 +2672,6 @@ 'device_class': 'temperature', 'device_file': '/A6.111111111111/temperature', 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -2777,7 +2729,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VAD', 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -2835,7 +2786,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VDD', 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -2893,7 +2843,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/vis', 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -2948,7 +2897,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_corrected', 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3003,7 +2951,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_raw', 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, 'state_class': , 'unit_of_measurement': '%', }), @@ -3061,7 +3008,6 @@ 'device_class': 'temperature', 'device_file': '/EF.111111111111/humidity/temperature', 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -3119,7 +3065,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.2', 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, 'state_class': , 'unit_of_measurement': , }), @@ -3177,7 +3122,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.3', 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, 'state_class': , 'unit_of_measurement': , }), @@ -3232,7 +3176,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.0', 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3287,7 +3230,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.1', 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index d819fdd0d54..025fbe1b64b 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/05.111111111111/PIO', 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.05_111111111111_programmed_input_output', @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.A', 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_a', @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.B', 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_b', @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.A', 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_a', @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.B', 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_b', @@ -289,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/26.111111111111/IAD', 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.26_111111111111_current_a_d_control', @@ -339,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.0', 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_0', @@ -389,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.1', 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_1', @@ -439,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.2', 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_2', @@ -489,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.3', 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_3', @@ -539,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.4', 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_4', @@ -589,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.5', 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_5', @@ -639,7 +627,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.6', 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_6', @@ -689,7 +676,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.7', 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_7', @@ -739,7 +725,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.0', 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_0', @@ -789,7 +774,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.1', 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_1', @@ -839,7 +823,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.2', 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_2', @@ -889,7 +872,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.3', 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_3', @@ -939,7 +921,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.4', 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_4', @@ -989,7 +970,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.5', 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_5', @@ -1039,7 +1019,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.6', 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_6', @@ -1089,7 +1068,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.7', 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_7', @@ -1139,7 +1117,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.A', 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', @@ -1189,7 +1166,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.B', 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', @@ -1239,7 +1215,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/A6.111111111111/IAD', 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.a6_111111111111_current_a_d_control', @@ -1289,7 +1264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.0', 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', @@ -1339,7 +1313,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.1', 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', @@ -1389,7 +1362,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.2', 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', @@ -1439,7 +1411,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.3', 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', @@ -1489,7 +1460,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.0', 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', @@ -1539,7 +1509,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.1', 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', @@ -1589,7 +1558,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.2', 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', @@ -1639,7 +1607,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.3', 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', @@ -1689,7 +1656,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.0', 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_0', @@ -1739,7 +1705,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.1', 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_1', @@ -1789,7 +1754,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.2', 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_2', @@ -1839,7 +1803,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.3', 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_3', diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 0748481c40b..ace7afb5645 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,6 +1,5 @@ """Tests for 1-Wire config flow.""" -from copy import deepcopy from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -63,27 +62,6 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_options( - hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock -) -> None: - """Test update options triggers reload.""" - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED - assert owproxy.call_count == 1 - - new_options = deepcopy(dict(config_entry.options)) - new_options["device_options"].clear() - hass.config_entries.async_update_entry(config_entry, options=new_options) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED - assert owproxy.call_count == 2 - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_registry( hass: HomeAssistant, diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 689711888d8..f8580c2b257 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -1,90 +1,71 @@ """Tests for the Onkyo integration.""" -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +from aioonkyo import ReceiverInfo -from homeassistant.components.onkyo.receiver import Receiver, ReceiverInfo -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +RECEIVER_INFO = ReceiverInfo( + host="192.168.0.101", + ip="192.168.0.101", + model_name="TX-NR7100", + identifier="0009B0123456", +) -def create_receiver_info(id: int) -> ReceiverInfo: - """Create an empty receiver info object for testing.""" - return ReceiverInfo( - host=f"host {id}", - port=id, - model_name=f"type {id}", - identifier=f"id{id}", - ) +RECEIVER_INFO_2 = ReceiverInfo( + host="192.168.0.102", + ip="192.168.0.102", + model_name="TX-RZ50", + identifier="0009B0ABCDEF", +) -def create_connection(id: int) -> Mock: - """Create an mock connection object for testing.""" - connection = Mock() - connection.host = f"host {id}" - connection.port = 0 - connection.name = f"type {id}" - connection.identifier = f"id{id}" - return connection +@contextmanager +def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]: + """Mock discovery functions.""" + async def get_info(host: str) -> ReceiverInfo | None: + """Get receiver info by host.""" + for info in receiver_infos: + if info.host == host: + return info + return None -def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: - """Create a config entry from receiver info.""" - data = {CONF_HOST: info.host} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } + def get_infos(host: str) -> MagicMock: + """Get receiver infos from broadcast.""" + discover_mock = MagicMock() + discover_mock.__aiter__.return_value = receiver_infos + return discover_mock - return MockConfigEntry( - data=data, - options=options, - title=info.model_name, - domain="onkyo", - unique_id=info.identifier, - ) - - -def create_empty_config_entry() -> MockConfigEntry: - """Create an empty config entry for use in unit tests.""" - data = {CONF_HOST: ""} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } - - return MockConfigEntry( - data=data, - options=options, - title="Unit test Onkyo", - domain="onkyo", - unique_id="onkyo_unique_id", - ) - - -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry, receiver_info: ReceiverInfo -) -> None: - """Fixture for setting up the component.""" - - config_entry.add_to_hass(hass) - - mock_receiver = AsyncMock() - mock_receiver.conn.close = Mock() - mock_receiver.callbacks.connect = Mock() - mock_receiver.callbacks.update = Mock() + discover_kwargs = {} + interview_kwargs = {} + if receiver_infos is None: + discover_kwargs["side_effect"] = OSError + interview_kwargs["side_effect"] = OSError + else: + discover_kwargs["new"] = get_infos + interview_kwargs["new"] = get_info with ( patch( - "homeassistant.components.onkyo.async_interview", - return_value=receiver_info, + "homeassistant.components.onkyo.receiver.aioonkyo.discover", + **discover_kwargs, + ), + patch( + "homeassistant.components.onkyo.receiver.aioonkyo.interview", + **interview_kwargs, ), - patch.object(Receiver, "async_create", return_value=mock_receiver), ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + yield + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index abbe39dd966..6528168f723 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -1,74 +1,180 @@ -"""Configure tests for the Onkyo integration.""" +"""Common fixtures for the Onkyo tests.""" -from unittest.mock import patch +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from aioonkyo import Code, Instruction, Kind, Receiver, Status, Zone, status import pytest from homeassistant.components.onkyo.const import DOMAIN -from . import create_connection +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery from tests.common import MockConfigEntry -@pytest.fixture(name="config_entry") +@pytest.fixture(autouse=True) +def mock_default_discovery() -> Generator[None]: + """Mock the discovery functions with default info.""" + with ( + patch.multiple( + "homeassistant.components.onkyo.receiver", + DEVICE_INTERVIEW_TIMEOUT=1, + DEVICE_DISCOVERY_TIMEOUT=1, + ), + mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]), + ): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock integration setup.""" + with patch( + "homeassistant.components.onkyo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_connect() -> Generator[AsyncMock]: + """Mock an Onkyo connect.""" + with patch( + "homeassistant.components.onkyo.receiver.connect", + ) as connect: + yield connect.return_value.__aenter__ + + +INITIAL_MESSAGES = [ + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Volume(Code.from_kind_zone(Kind.VOLUME, Zone.ZONE2), None, 50), + status.Muting( + Code.from_kind_zone(Kind.MUTING, Zone.MAIN), None, status.Muting.Param.OFF + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.MAIN), + None, + status.InputSource.Param("24"), + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.ZONE2), + None, + status.InputSource.Param("00"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.MAIN), + None, + status.ListeningMode.Param("01"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.ZONE2), + None, + status.ListeningMode.Param("00"), + ), + status.HDMIOutput( + Code.from_kind_zone(Kind.HDMI_OUTPUT, Zone.MAIN), + None, + status.HDMIOutput.Param.MAIN, + ), + status.TunerPreset(Code.from_kind_zone(Kind.TUNER_PRESET, Zone.MAIN), None, 1), + status.AudioInformation( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + auto_phase_control_phase="Normal", + ), + status.VideoInformation( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + input_color_depth="24bit", + ), + status.FLDisplay(Code.from_kind_zone(Kind.FL_DISPLAY, Zone.MAIN), None, "LALALA"), + status.NotAvailable( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + Kind.AUDIO_INFORMATION, + ), + status.NotAvailable( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + Kind.VIDEO_INFORMATION, + ), + status.Raw(None, None), +] + + +@pytest.fixture +def read_queue() -> asyncio.Queue[Status | None]: + """Read messages queue.""" + return asyncio.Queue() + + +@pytest.fixture +def writes() -> list[Instruction]: + """Written messages.""" + return [] + + +@pytest.fixture +def mock_receiver( + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], + writes: list[Instruction], +) -> AsyncMock: + """Mock an Onkyo receiver.""" + receiver_class = AsyncMock(Receiver, auto_spec=True) + receiver = receiver_class.return_value + + for message in INITIAL_MESSAGES: + read_queue.put_nowait(message) + + async def read() -> Status: + return await read_queue.get() + + async def write(message: Instruction) -> None: + writes.append(message) + + receiver.read = read + receiver.write = write + + mock_connect.return_value = receiver + + return receiver + + +@pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Create Onkyo entry in Home Assistant.""" + """Mock a config entry.""" + data = {"host": RECEIVER_INFO.host} + options = { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": {"12": "TV", "24": "FM Radio"}, + "listening_modes": {"00": "Stereo", "04": "THX"}, + } + return MockConfigEntry( domain=DOMAIN, - title="Onkyo", - data={}, + title=RECEIVER_INFO.model_name, + unique_id=RECEIVER_INFO.identifier, + data=data, + options=options, ) - - -@pytest.fixture(autouse=True) -def patch_timeouts(): - """Patch timeouts to avoid tests waiting.""" - with patch.multiple( - "homeassistant.components.onkyo.receiver", - DEVICE_INTERVIEW_TIMEOUT=0, - DEVICE_DISCOVERY_TIMEOUT=0, - ): - yield - - -@pytest.fixture -async def default_mock_discovery(): - """Mock discovery with a single device.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(create_connection(1)) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield - - -@pytest.fixture -async def stub_mock_discovery(): - """Mock discovery with no devices.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - pass - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield - - -@pytest.fixture -async def empty_mock_discovery(): - """Mock discovery with an empty connection.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(None) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield diff --git a/tests/components/onkyo/snapshots/test_media_player.ambr b/tests/components/onkyo/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..32717a8af43 --- /dev/null +++ b/tests/components/onkyo/snapshots/test_media_player.ambr @@ -0,0 +1,203 @@ +# serializer version: 1 +# name: test_entities[media_player.tx_nr7100-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'audio_information': dict({ + 'auto_phase_control_phase': 'Normal', + }), + 'friendly_name': 'TX-NR7100', + 'is_volume_muted': False, + 'preset': 1, + 'sound_mode': 'DIRECT', + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source': 'FM Radio', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'video_information': dict({ + 'input_color_depth': '24bit', + }), + 'video_out': 'yes,out', + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100 Zone 2', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 2', + 'sound_mode': 'Stereo', + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source': 'VIDEO1 ··· VCR/DVR ··· STB/DVR', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'volume_level': 0.625, + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100 Zone 3', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 3', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 92a4a34e8fb..b56ab4b7028 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,11 +1,11 @@ """Test Onkyo config flow.""" -from unittest.mock import patch +from contextlib import AbstractContextManager, nullcontext +from aioonkyo import ReceiverInfo import pytest from homeassistant import config_entries -from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -17,385 +17,334 @@ from homeassistant.components.onkyo.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, SsdpServiceInfo, ) -from . import ( - create_config_entry_from_info, - create_connection, - create_empty_config_entry, - create_receiver_info, - setup_integration, -) +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery, setup_integration from tests.common import MockConfigEntry -async def test_user_initial_menu(hass: HomeAssistant) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert init_result["type"] is FlowResultType.MENU - # Check if the values are there, but ignore order - assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} +def _receiver_display_name(receiver_info: ReceiverInfo) -> str: + return f"{receiver_info.model_name} ({receiver_info.host})" -async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> None: - """Test valid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "host 1"}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 1 (host 1)" - - -async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> None: - """Test invalid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "cannot_connect" - - -async def test_manual_valid_host_unexpected_error( - hass: HomeAssistant, empty_mock_discovery -) -> None: - """Test valid host entered.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "unknown" - - -async def test_discovery_and_no_devices_discovered( - hass: HomeAssistant, stub_mock_discovery -) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "no_devices_found" - - -async def test_discovery_with_exception( - hass: HomeAssistant, empty_mock_discovery -) -> None: - """Test discovery which throws an unexpected exception.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "unknown" - - -async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> None: - """Test discovery with a new and an existing entry.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(1)) - await discovery_callback(create_connection(2)) - - with ( - patch("pyeiscp.Connection.discover", new=mock_discover), - # Fake it like the first entry was already added - patch.object(OnkyoConfigFlow, "_async_current_ids", return_value=["id1"]), - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert form_result["type"] is FlowResultType.FORM - - assert form_result["data_schema"] is not None - schema = form_result["data_schema"].schema - container = schema["device"].container - assert container == {"id2": "type 2 (host 2)"} - - -async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: - """Test discovery after a selection.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(42)) - await discovery_callback(create_connection(0)) - - with patch("pyeiscp.Connection.discover", new=mock_discover): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={"device": "id42"}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" - - -async def test_ssdp_discovery_success( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test SSDP discovery with valid host.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", - ) - +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual(hass: HomeAssistant) -> None: + """Test successful manual.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" - select_result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "volume_resolution": 200, - "input_sources": ["TV"], - "listening_modes": ["THX"], + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], }, ) - assert select_result["type"] is FlowResultType.CREATE_ENTRY - assert select_result["data"]["host"] == "192.168.1.100" - assert select_result["result"].unique_id == "id1" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name -async def test_ssdp_discovery_already_configured( - hass: HomeAssistant, default_mock_discovery +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "cannot_connect"), + (mock_discovery([RECEIVER_INFO]), "cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_recoverable_error( + hass: HomeAssistant, mock_discovery: AbstractContextManager, error_reason: str ) -> None: - """Test SSDP discovery with already configured device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100"}, - unique_id="id1", + """Test manual with a recoverable error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - config_entry.add_to_hass(hass) - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error_reason} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test manual with an error.""" + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO.host} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: - """Test SSDP discovery with host info error.""" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful eiscp discovery.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "eiscp_discovery" + + devices = result["data_schema"].schema["device"].container + assert devices == { + RECEIVER_INFO_2.identifier: _receiver_display_name(RECEIVER_INFO_2) + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device": RECEIVER_INFO_2.identifier} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name + + +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "no_devices_found"), + (mock_discovery([RECEIVER_INFO]), "no_devices_found"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test eiscp discovery with an error.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_reason + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful SSDP discovery.""" + await setup_integration(hass, mock_config_entry) + discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", + ssdp_location=f"http://{RECEIVER_INFO_2.host}:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", ) - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - side_effect=OSError, - ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name + + +@pytest.mark.parametrize( + ("ssdp_location", "mock_discovery", "error_reason"), + [ + (None, nullcontext(), "unknown"), + ("http://", nullcontext(), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery(None), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery([]), "cannot_connect"), + ( + f"http://{RECEIVER_INFO_2.host}:8080", + mock_discovery([RECEIVER_INFO]), + "cannot_connect", + ), + (f"http://{RECEIVER_INFO.host}:8080", nullcontext(), "already_configured"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + ssdp_location: str | None, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test SSDP discovery with an error.""" + await setup_integration(hass, mock_config_entry) + + discovery_info = SsdpServiceInfo( + ssdp_location=ssdp_location, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + with mock_discovery: result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert result["reason"] == error_reason -async def test_ssdp_discovery_host_none_info( - hass: HomeAssistant, stub_mock_discovery -) -> None: - """Test SSDP discovery with host info error.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_ssdp_discovery_no_location( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test SSDP discovery with no location.""" - discovery_info = SsdpServiceInfo( - ssdp_location=None, - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_ssdp_discovery_no_host( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test SSDP discovery with no host.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_configure_no_resolution( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configure with no resolution set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"input_sources": ["TV"]}, - ) - - -async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_configure(hass: HomeAssistant) -> None: """Test receiver configure.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "manual"}, - ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + assert result["description_placeholders"]["name"] == _receiver_display_name( + RECEIVER_INFO ) result = await hass.config_entries.flow.async_configure( @@ -406,6 +355,8 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} @@ -417,6 +368,8 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: OPTION_LISTENING_MODES: [], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} @@ -428,6 +381,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["options"] == { OPTION_VOLUME_RESOLUTION: 200, @@ -437,103 +391,69 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: } -async def test_configure_invalid_resolution_set( - hass: HomeAssistant, default_mock_discovery +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test receiver configure with invalid resolution.""" + """Test successful reconfigure flow.""" + await setup_integration(hass, mock_config_entry) - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) + old_host = mock_config_entry.data[CONF_HOST] + old_options = mock_config_entry.options - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 42, "input_sources": ["TV"]}, - ) - - -async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: - """Test the reconfigure config flow.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) - - old_host = config_entry.data[CONF_HOST] - old_options = config_entry.options - - result = await config_entry.start_reconfigure_flow(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": receiver_info.host} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "configure_receiver" - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={OPTION_VOLUME_RESOLUTION: 200}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: mock_config_entry.data[CONF_HOST]} ) - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" - assert config_entry.data[CONF_HOST] == old_host - assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={OPTION_VOLUME_RESOLUTION: 200} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.data[CONF_HOST] == old_host + assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 for option, option_value in old_options.items(): if option == OPTION_VOLUME_RESOLUTION: continue - assert config_entry.options[option] == option_value + assert mock_config_entry.options[option] == option_value -async def test_reconfigure_new_device(hass: HomeAssistant) -> None: - """Test the reconfigure config flow with new device.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow with an error.""" + await setup_integration(hass, mock_config_entry) - old_unique_id = receiver_info.identifier + old_unique_id = mock_config_entry.unique_id - result = await config_entry.start_reconfigure_flow(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) - mock_connection = create_connection(2) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" - # Create mock discover that calls callback immediately - async def mock_discover(host, discovery_callback, timeout): - await discovery_callback(mock_connection) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": mock_connection.host} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "unique_id_mismatch" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" # unique id should remain unchanged - assert config_entry.unique_id == old_unique_id + assert mock_config_entry.unique_id == old_unique_id +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( "ignore_missing_translations", [ @@ -545,16 +465,18 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: ] ], ) -async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test options flow.""" + await setup_integration(hass, mock_config_entry) - receiver_info = create_receiver_info(1) - config_entry = create_empty_config_entry() - await setup_integration(hass, config_entry, receiver_info) + old_volume_resolution = mock_config_entry.options[OPTION_VOLUME_RESOLUTION] - old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 17086a3088e..144947dcbe1 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -2,71 +2,85 @@ from __future__ import annotations -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock +from aioonkyo import Status import pytest -from homeassistant.components.onkyo import async_setup_entry from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from . import create_empty_config_entry, create_receiver_info, setup_integration +from . import mock_discovery, setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_receiver") async def test_load_unload_entry( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) + assert mock_config_entry.state is ConfigEntryState.LOADED - assert config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(config_entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_entry( +@pytest.mark.parametrize( + "receiver_infos", + [ + None, + [], + ], +) +async def test_initialization_failure( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, + receiver_infos, ) -> None: - """Test update options.""" + """Test initialization failure.""" + with mock_discovery(receiver_infos): + await setup_integration(hass, mock_config_entry) - with patch.object(hass.config_entries, "async_reload", return_value=True): - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) - - # Force option change - assert hass.config_entries.async_update_entry( - config_entry, options={"option": "new_value"} - ) - await hass.async_block_till_done() - - hass.config_entries.async_reload.assert_called_with(config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_no_connection( +async def test_connection_failure( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, ) -> None: - """Test update options.""" + """Test connection failure.""" + mock_connect.side_effect = OSError - config_entry = create_empty_config_entry() - config_entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch( - "homeassistant.components.onkyo.async_interview", - return_value=None, - ), - pytest.raises(ConfigEntryNotReady), - ): - await async_setup_entry(hass, config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_receiver") +async def test_reconnect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], +) -> None: + """Test reconnect.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_connect.reset_mock() + + assert mock_connect.call_count == 0 + + read_queue.put_nowait(None) # Simulate a disconnect + await asyncio.sleep(0) + + assert mock_connect.call_count == 1 diff --git a/tests/components/onkyo/test_media_player.py b/tests/components/onkyo/test_media_player.py new file mode 100644 index 00000000000..3d22e3b1af8 --- /dev/null +++ b/tests/components/onkyo/test_media_player.py @@ -0,0 +1,230 @@ +"""Test Onkyo media player platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from aioonkyo import Instruction, Zone, command +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, +) +from homeassistant.components.onkyo.services import ( + ATTR_HDMI_OUTPUT, + SERVICE_SELECT_HDMI_OUTPUT, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "media_player.tx_nr7100" +ENTITY_ID_ZONE_2 = "media_player.tx_nr7100_zone_2" +ENTITY_ID_ZONE_3 = "media_player.tx_nr7100_zone_3" + + +@pytest.fixture(autouse=True) +async def auto_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: AsyncMock, + writes: list[Instruction], +) -> AsyncGenerator[None]: + """Auto setup integration.""" + with ( + patch( + "homeassistant.components.onkyo.media_player.AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME", + 0, + ), + patch("homeassistant.components.onkyo.PLATFORMS", [Platform.MEDIA_PLAYER]), + ): + await setup_integration(hass, mock_config_entry) + writes.clear() + yield + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("action", "action_data", "message"), + [ + (SERVICE_TURN_ON, {}, command.Power(Zone.MAIN, command.Power.Param.ON)), + (SERVICE_TURN_OFF, {}, command.Power(Zone.MAIN, command.Power.Param.STANDBY)), + ( + SERVICE_VOLUME_SET, + {ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + command.Volume(Zone.MAIN, 40), + ), + (SERVICE_VOLUME_UP, {}, command.Volume(Zone.MAIN, command.Volume.Param.UP)), + (SERVICE_VOLUME_DOWN, {}, command.Volume(Zone.MAIN, command.Volume.Param.DOWN)), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: True}, + command.Muting(Zone.MAIN, command.Muting.Param.ON), + ), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: False}, + command.Muting(Zone.MAIN, command.Muting.Param.OFF), + ), + ], +) +async def test_actions( + hass: HomeAssistant, + writes: list[Instruction], + action: str, + action_data: dict, + message: Instruction, +) -> None: + """Test actions.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_ID, **action_data}, + blocking=True, + ) + assert writes[0] == message + + +async def test_select_source(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test select source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "TV"}, + blocking=True, + ) + assert writes[0] == command.InputSource(Zone.MAIN, command.InputSource.Param("12")) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "InvalidSource"}, + blocking=True, + ) + assert not writes + + +async def test_select_sound_mode( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select sound mode.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "THX"}, + blocking=True, + ) + assert writes[0] == command.ListeningMode( + Zone.MAIN, command.ListeningMode.Param("04") + ) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "InvalidMode"}, + blocking=True, + ) + assert not writes + + +async def test_play_media(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test play media (radio preset).""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert writes[0] == command.TunerPreset(Zone.MAIN, 5) + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_2, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_3, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + +async def test_select_hdmi_output( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select hdmi output.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_HDMI_OUTPUT, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HDMI_OUTPUT: "sub"}, + blocking=True, + ) + assert writes[0] == command.HDMIOutput(command.HDMIOutput.Param.BOTH) diff --git a/tests/components/open_router/__init__.py b/tests/components/open_router/__init__.py new file mode 100644 index 00000000000..3858e866315 --- /dev/null +++ b/tests/components/open_router/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the OpenRouter integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py new file mode 100644 index 00000000000..33ca4d790c9 --- /dev/null +++ b/tests/components/open_router/conftest.py @@ -0,0 +1,154 @@ +"""Fixtures for OpenRouter integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, patch + +from openai.types import CompletionUsage +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest +from python_open_router import ModelsDataWrapper + +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.open_router.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def enable_assist() -> bool: + """Mock conversation subentry data.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def ai_task_data_subentry_data() -> dict[str, Any]: + """Mock AI task subentry data.""" + return { + CONF_MODEL: "google/gemini-1.5-pro", + } + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + conversation_subentry_data: dict[str, Any], + ai_task_data_subentry_data: dict[str, Any], +) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="OpenRouter", + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data=conversation_subentry_data, + subentry_id="ABCDEF", + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ), + ConfigSubentryData( + data=ai_task_data_subentry_data, + subentry_id="ABCDEG", + subentry_type="ai_task_data", + title="Gemini 1.5 Pro", + unique_id=None, + ), + ], + ) + + +@dataclass +class Model: + """Mock model data.""" + + id: str + name: str + + +@pytest.fixture +async def mock_openai_client() -> AsyncGenerator[AsyncMock]: + """Initialize integration.""" + with patch("homeassistant.components.open_router.AsyncOpenAI") as mock_client: + client = mock_client.return_value + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +async def mock_open_router_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Initialize integration.""" + with patch( + "homeassistant.components.open_router.config_flow.OpenRouterClient", + autospec=True, + ) as mock_client: + client = mock_client.return_value + models = await async_load_fixture(hass, "models.json", DOMAIN) + client.get_models.return_value = ModelsDataWrapper.from_json(models).data + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +async def get_generator_from_data[DataT](items: list[DataT]) -> AsyncGenerator[DataT]: + """Return async generator.""" + for item in items: + yield item diff --git a/tests/components/open_router/fixtures/models.json b/tests/components/open_router/fixtures/models.json new file mode 100644 index 00000000000..b17f584c0e6 --- /dev/null +++ b/tests/components/open_router/fixtures/models.json @@ -0,0 +1,93 @@ +{ + "data": [ + { + "id": "openai/gpt-3.5-turbo", + "canonical_slug": "openai/gpt-3.5-turbo", + "hugging_face_id": null, + "name": "OpenAI: GPT-3.5 Turbo", + "created": 1695859200, + "description": "This model is a variant of GPT-3.5 Turbo tuned for instructional prompts and omitting chat-related optimizations. Training data: up to Sep 2021.", + "context_length": 4095, + "architecture": { + "modality": "text->text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "GPT", + "instruct_type": "chatml" + }, + "pricing": { + "prompt": "0.0000015", + "completion": "0.000002", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0" + }, + "top_provider": { + "context_length": 4095, + "max_completion_tokens": 4096, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", + "response_format" + ] + }, + { + "id": "openai/gpt-4", + "canonical_slug": "openai/gpt-4", + "hugging_face_id": null, + "name": "OpenAI: GPT-4", + "created": 1685232000, + "description": "OpenAI's flagship model, GPT-4 is a large-scale multimodal language model capable of solving difficult problems with greater accuracy than previous models due to its broader general knowledge and advanced reasoning capabilities. Training data: up to Sep 2021.", + "context_length": 8191, + "architecture": { + "modality": "text->text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": "0.00003", + "completion": "0.00006", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0" + }, + "top_provider": { + "context_length": 8191, + "max_completion_tokens": 4096, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "tools", + "tool_choice", + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", + "structured_outputs", + "response_format" + ] + } + ] +} diff --git a/tests/components/open_router/snapshots/test_ai_task.ambr b/tests/components/open_router/snapshots/test_ai_task.ambr new file mode 100644 index 00000000000..0839f6fef9b --- /dev/null +++ b/tests/components/open_router/snapshots/test_ai_task.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_all_entities[ai_task.gemini_1_5_pro-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'ai_task', + 'entity_category': None, + 'entity_id': 'ai_task.gemini_1_5_pro', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEG', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ai_task.gemini_1_5_pro-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gemini 1.5 Pro', + 'supported_features': , + }), + 'context': , + 'entity_id': 'ai_task.gemini_1_5_pro', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr new file mode 100644 index 00000000000..19b5785a9eb --- /dev/null +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -0,0 +1,163 @@ +# serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/open_router/test_ai_task.py b/tests/components/open_router/test_ai_task.py new file mode 100644 index 00000000000..0b6c2933be7 --- /dev/null +++ b/tests/components/open_router/test_ai_task.py @@ -0,0 +1,210 @@ +"""Test AI Task structured data generation.""" + +from unittest.mock import AsyncMock, patch + +from openai.types import CompletionUsage +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components import ai_task +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.open_router.PLATFORMS", + [Platform.AI_TASK], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task data generation.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "ai_task.gemini_1_5_pro" + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="The test data", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "The test data" + + +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task structured data generation.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content='{"characters": ["Mario", "Luigi"]}', + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.gemini_1_5_pro", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + assert result.data == {"characters": ["Mario", "Luigi"]} + assert mock_openai_client.chat.completions.create.call_args_list[0][1][ + "response_format" + ] == { + "json_schema": { + "name": "Test Task", + "schema": { + "properties": { + "characters": { + "items": {"type": "string"}, + "type": "array", + } + }, + "required": ["characters"], + "type": "object", + }, + "strict": True, + }, + "type": "json_schema", + } + + +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task with invalid JSON response.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="INVALID JSON RESPONSE", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + with pytest.raises( + HomeAssistantError, match="Error with OpenRouter structured response" + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.gemini_1_5_pro", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py new file mode 100644 index 00000000000..b406e75507b --- /dev/null +++ b/tests/components/open_router/test_config_flow.py @@ -0,0 +1,240 @@ +"""Test the OpenRouter config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_open_router import OpenRouterError + +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "bla"} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OpenRouterError("exception"), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle errors from the OpenRouter API.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_open_router_client.get_key_data.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_open_router_client.get_key_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry already exists.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "openai/gpt-3.5-turbo", "label": "OpenAI: GPT-3.5 Turbo"}, + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "openai/gpt-3.5-turbo", "label": "OpenAI: GPT-3.5 Turbo"}, + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + } + + +async def test_create_ai_task( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI Task.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_MODEL: "openai/gpt-4"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_MODEL: "openai/gpt-4"} + + +@pytest.mark.parametrize( + "subentry_type", + ["conversation", "ai_task_data"], +) +@pytest.mark.parametrize( + ("exception", "reason"), + [(OpenRouterError("exception"), "cannot_connect"), (Exception, "unknown")], +) +async def test_subentry_exceptions( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + subentry_type: str, + exception: Exception, + reason: str, +) -> None: + """Test subentry flow exceptions.""" + await setup_integration(hass, mock_config_entry) + + mock_open_router_client.get_models.side_effect = exception + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, subentry_type), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py new file mode 100644 index 00000000000..edd47572120 --- /dev/null +++ b/tests/components/open_router/test_conversation.py @@ -0,0 +1,169 @@ +"""Tests for the OpenRouter integration.""" + +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.const import Platform +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, intent + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.open_router.PLATFORMS", + [Platform.CONVERSATION], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "openai/gpt-3.5-turbo" + assert call["extra_headers"] == { + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + "X-Title": "Home Assistant", + } + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + async def completion_result(*args, messages, **kwargs): + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + mock_openai_client.chat.completions.create = completion_result + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index dda2fe16a63..e8effca3bc5 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1 +1,403 @@ """Tests for the OpenAI Conversation integration.""" + +from openai.types.responses import ( + ResponseCodeInterpreterCallCodeDeltaEvent, + ResponseCodeInterpreterCallCodeDoneEvent, + ResponseCodeInterpreterCallCompletedEvent, + ResponseCodeInterpreterCallInProgressEvent, + ResponseCodeInterpreterCallInterpretingEvent, + ResponseCodeInterpreterToolCall, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, + ResponseReasoningSummaryPartAddedEvent, + ResponseReasoningSummaryPartDoneEvent, + ResponseReasoningSummaryTextDeltaEvent, + ResponseReasoningSummaryTextDoneEvent, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, + ResponseWebSearchCallCompletedEvent, + ResponseWebSearchCallInProgressEvent, + ResponseWebSearchCallSearchingEvent, +) +from openai.types.responses.response_code_interpreter_tool_call import OutputLogs +from openai.types.responses.response_function_web_search import ActionSearch +from openai.types.responses.response_reasoning_item import Summary + + +def create_message_item( + id: str, text: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(text, str): + text = [text] + + content = ResponseOutputText(annotations=[], text="", type="output_text") + events = [ + ResponseOutputItemAddedEvent( + item=ResponseOutputMessage( + id=id, + content=[], + type="message", + role="assistant", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseContentPartAddedEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.added", + ), + ] + + content.text = "".join(text) + events.extend( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=id, + logprobs=[], + output_index=output_index, + sequence_number=0, + type="response.output_text.delta", + ) + for delta in text + ) + + events.extend( + [ + ResponseTextDoneEvent( + content_index=0, + item_id=id, + logprobs=[], + output_index=output_index, + text="".join(text), + sequence_number=0, + type="response.output_text.done", + ), + ResponseContentPartDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.done", + ), + ResponseOutputItemDoneEvent( + item=ResponseOutputMessage( + id=id, + content=[content], + role="assistant", + status="completed", + type="message", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events + + +def create_function_tool_call_item( + id: str, arguments: str | list[str], call_id: str, name: str, output_index: int +) -> list[ResponseStreamEvent]: + """Create a function tool call item.""" + if isinstance(arguments, str): + arguments = [arguments] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="", + call_id=call_id, + name=name, + type="function_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ) + ] + + events.extend( + ResponseFunctionCallArgumentsDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.delta", + ) + for delta in arguments + ) + + events.append( + ResponseFunctionCallArgumentsDoneEvent( + arguments="".join(arguments), + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.done", + ) + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="".join(arguments), + call_id=call_id, + name=name, + type="function_call", + status="completed", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ) + ) + + return events + + +def create_reasoning_item( + id: str, + output_index: int, + reasoning_summary: list[list[str]] | list[str] | str | None = None, +) -> list[ResponseStreamEvent]: + """Create a reasoning item.""" + + if reasoning_summary is None: + reasoning_summary = [[]] + elif isinstance(reasoning_summary, str): + reasoning_summary = [reasoning_summary] + if isinstance(reasoning_summary, list) and all( + isinstance(item, str) for item in reasoning_summary + ): + reasoning_summary = [reasoning_summary] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + encrypted_content="AAA", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ) + ] + + for summary_index, summary in enumerate(reasoning_summary): + events.append( + ResponseReasoningSummaryPartAddedEvent( + item_id=id, + output_index=output_index, + part={"text": "", "type": "summary_text"}, + sequence_number=0, + summary_index=summary_index, + type="response.reasoning_summary_part.added", + ) + ) + events.extend( + ResponseReasoningSummaryTextDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + summary_index=summary_index, + type="response.reasoning_summary_text.delta", + ) + for delta in summary + ) + events.extend( + [ + ResponseReasoningSummaryTextDoneEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + summary_index=summary_index, + text="".join(summary), + type="response.reasoning_summary_text.done", + ), + ResponseReasoningSummaryPartDoneEvent( + item_id=id, + output_index=output_index, + part={"text": "".join(summary), "type": "summary_text"}, + sequence_number=0, + summary_index=summary_index, + type="response.reasoning_summary_part.done", + ), + ] + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseReasoningItem( + id=id, + summary=[ + Summary(text="".join(summary), type="summary_text") + for summary in reasoning_summary + ], + type="reasoning", + status=None, + encrypted_content="AAABBB", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ) + + return events + + +def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a web search call item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionWebSearch( + id=id, + status="in_progress", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseWebSearchCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.in_progress", + ), + ResponseWebSearchCallSearchingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.searching", + ), + ResponseWebSearchCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseFunctionWebSearch( + id=id, + status="completed", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + + +def create_code_interpreter_item( + id: str, code: str | list[str], output_index: int, logs: str | None = None +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(code, str): + code = [code] + + container_id = "cntr_A" + events = [ + ResponseOutputItemAddedEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code="", + container_id=container_id, + outputs=None, + type="code_interpreter_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseCodeInterpreterCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.in_progress", + ), + ] + + events.extend( + ResponseCodeInterpreterCallCodeDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call_code.delta", + ) + for delta in code + ) + + code = "".join(code) + + events.extend( + [ + ResponseCodeInterpreterCallCodeDoneEvent( + item_id=id, + output_index=output_index, + code=code, + sequence_number=0, + type="response.code_interpreter_call_code.done", + ), + ResponseCodeInterpreterCallInterpretingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.interpreting", + ), + ResponseCodeInterpreterCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code=code, + container_id=container_id, + outputs=[OutputLogs(type="logs", logs=logs)] if logs else None, + status="completed", + type="code_interpreter_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 4639d0dc8e0..38d8967e6c5 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,9 +1,32 @@ """Tests helpers.""" -from unittest.mock import patch +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch +from openai.types import ResponseFormatText +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseError, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ResponseInProgressEvent, + ResponseOutputItemDoneEvent, + ResponseTextConfig, +) +from openai.types.responses.response import IncompleteDetails import pytest +from homeassistant.components.openai_conversation.const import ( + CONF_CHAT_MODEL, + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, + RECOMMENDED_AI_TASK_OPTIONS, +) +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -13,7 +36,15 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_conversation_subentry_data() -> dict[str, Any]: + """Mock subentry data.""" + return {} + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, mock_conversation_subentry_data: dict[str, Any] +) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( title="OpenAI", @@ -21,6 +52,22 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: data={ "api_key": "bla", }, + version=2, + minor_version=3, + subentries_data=[ + ConfigSubentryData( + data=mock_conversation_subentry_data, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ), + ConfigSubentryData( + data=RECOMMENDED_AI_TASK_OPTIONS, + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ], ) entry.add_to_hass(hass) return entry @@ -31,8 +78,23 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, + ) + return mock_config_entry + + +@pytest.fixture +def mock_config_entry_with_reasoning_model( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "gpt-5-mini"}, ) return mock_config_entry @@ -53,3 +115,97 @@ async def mock_init_component( async def setup_ha(hass: HomeAssistant) -> None: """Set up Home Assistant.""" assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +def mock_create_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(events, **kwargs): + response = Response( + id="resp_A", + created_at=1700000000, + error=None, + incomplete_details=None, + instructions=kwargs.get("instructions"), + metadata=kwargs.get("metadata", {}), + model=kwargs.get("model", "gpt-4o-mini"), + object="response", + output=[], + parallel_tool_calls=kwargs.get("parallel_tool_calls", True), + temperature=kwargs.get("temperature", 1.0), + tool_choice=kwargs.get("tool_choice", "auto"), + tools=kwargs.get("tools", []), + top_p=kwargs.get("top_p", 1.0), + max_output_tokens=kwargs.get("max_output_tokens", 100000), + previous_response_id=kwargs.get("previous_response_id"), + reasoning=kwargs.get("reasoning"), + status="in_progress", + text=kwargs.get( + "text", ResponseTextConfig(format=ResponseFormatText(type="text")) + ), + truncation=kwargs.get("truncation", "disabled"), + usage=None, + user=kwargs.get("user"), + store=kwargs.get("store", True), + ) + yield ResponseCreatedEvent( + response=response, + sequence_number=0, + type="response.created", + ) + yield ResponseInProgressEvent( + response=response, + sequence_number=1, + type="response.in_progress", + ) + sequence_number = 2 + response.status = "completed" + + for value in events: + if isinstance(value, ResponseOutputItemDoneEvent): + response.output.append(value.item) + elif isinstance(value, IncompleteDetails): + response.status = "incomplete" + response.incomplete_details = value + break + if isinstance(value, ResponseError): + response.status = "failed" + response.error = value + break + + value.sequence_number = sequence_number + sequence_number += 1 + yield value + + if isinstance(value, ResponseErrorEvent): + return + + if response.status == "incomplete": + yield ResponseIncompleteEvent( + response=response, + sequence_number=sequence_number, + type="response.incomplete", + ) + elif response.status == "failed": + yield ResponseFailedEvent( + response=response, + sequence_number=sequence_number, + type="response.failed", + ) + else: + yield ResponseCompletedEvent( + response=response, + sequence_number=sequence_number, + type="response.completed", + ) + + with patch( + "openai.resources.responses.AsyncResponses.create", + AsyncMock(), + ) as mock_create: + mock_create.side_effect = lambda **kwargs: mock_generator( + mock_create.return_value.pop(0), **kwargs + ) + + yield mock_create diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 0f874969aff..473d32a53f8 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,16 +1,71 @@ # serializer version: 1 +# name: test_code_interpreter + list([ + dict({ + 'content': 'Please use the python tool to calculate square root of 55555', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'arguments': '{"code": "import math\\nmath.sqrt(55555)", "container": "cntr_A"}', + 'call_id': 'ci_A', + 'name': 'code_interpreter', + 'type': 'function_call', + }), + dict({ + 'call_id': 'ci_A', + 'output': '{"output": [{"logs": "235.70108188126758\\n", "type": "logs"}]}', + 'type': 'function_call_output', + }), + dict({ + 'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.', + 'role': 'assistant', + 'type': 'message', + }), + dict({ + 'content': 'Thank you!', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'content': 'You are welcome!', + 'role': 'assistant', + 'type': 'message', + }), + ]) +# --- # name: test_function_call list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': 'Thinking', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.openai_conversation', + 'content': None, + 'native': ResponseReasoningItem(id='rs_A', summary=[], type='reasoning', content=None, encrypted_content='AAABBB', status=None), + 'role': 'assistant', + 'thinking_content': 'Thinking more', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.openai_conversation', + 'content': None, + 'native': None, + 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'call_call_1', 'tool_args': dict({ 'param1': 'call1', @@ -20,18 +75,21 @@ ]), }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', 'tool_result': 'value1', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'call_call_2', 'tool_args': dict({ 'param1': 'call2', @@ -41,32 +99,89 @@ ]), }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'role': 'tool_result', 'tool_call_id': 'call_call_2', 'tool_name': 'test_tool', 'tool_result': 'value2', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', + 'content': 'Cool', + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call.1 + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'encrypted_content': 'AAABBB', + 'id': 'rs_A', + 'summary': list([ + dict({ + 'text': 'Thinking', + 'type': 'summary_text', + }), + dict({ + 'text': 'Thinking more', + 'type': 'summary_text', + }), + ]), + 'type': 'reasoning', + }), + dict({ + 'arguments': '{"param1": "call1"}', + 'call_id': 'call_call_1', + 'name': 'test_tool', + 'type': 'function_call', + }), + dict({ + 'call_id': 'call_call_1', + 'output': '"value1"', + 'type': 'function_call_output', + }), + dict({ + 'arguments': '{"param1": "call2"}', + 'call_id': 'call_call_2', + 'name': 'test_tool', + 'type': 'function_call', + }), + dict({ + 'call_id': 'call_call_2', + 'output': '"value2"', + 'type': 'function_call_output', + }), + dict({ 'content': 'Cool', 'role': 'assistant', - 'tool_calls': None, + 'type': 'message', }), ]) # --- # name: test_function_call_without_reasoning list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ + 'external': False, 'id': 'call_call_1', 'tool_args': dict({ 'param1': 'call1', @@ -76,17 +191,52 @@ ]), }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', 'tool_result': 'value1', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': 'Cool', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) # --- +# name: test_web_search + list([ + dict({ + 'content': "What's on the latest news?", + 'role': 'user', + 'type': 'message', + }), + dict({ + 'action': dict({ + 'query': 'query', + 'type': 'search', + }), + 'id': 'ws_A', + 'status': 'completed', + 'type': 'web_search_call', + }), + dict({ + 'content': 'Home Assistant now supports ChatGPT Search in Assist', + 'role': 'assistant', + 'type': 'message', + }), + dict({ + 'content': 'Thank you!', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'content': 'You are welcome!', + 'role': 'assistant', + 'type': 'message', + }), + ]) +# --- diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr new file mode 100644 index 00000000000..f5006ac979f --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_devices[mock_conversation_subentry_data0] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-4o-mini', + 'model_id': None, + 'name': 'OpenAI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[mock_conversation_subentry_data1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-1o', + 'model_id': None, + 'name': 'OpenAI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py new file mode 100644 index 00000000000..14e3056c0e2 --- /dev/null +++ b/tests/components/openai_conversation/test_ai_task.py @@ -0,0 +1,208 @@ +"""Test AI Task platform of OpenAI Conversation integration.""" + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +import voluptuous as vol + +from homeassistant.components import ai_task, media_source +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from . import create_message_item + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.openai_ai_task" + + # Ensure entity is linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="The test data", output_index=0) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "The test data" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task structured data generation.""" + # Mock the OpenAI response stream with JSON data + mock_create_stream.return_value = [ + create_message_item( + id="msg_A", text='{"characters": ["Mario", "Luigi"]}', output_index=0 + ) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + assert result.data == {"characters": ["Mario", "Luigi"]} + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with invalid JSON response.""" + # Mock the OpenAI response stream with invalid JSON + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="INVALID JSON RESPONSE", output_index=0) + ] + + with pytest.raises( + HomeAssistantError, match="Error with OpenAI structured response" + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachments( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with attachments.""" + entity_id = "ai_task.openai_ai_task" + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi there!", output_index=0) + ] + + # Test with attachments + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + # patch.object(hass.config, "is_allowed_path", return_value=True), + patch( + "homeassistant.components.openai_conversation.entity.guess_file_type", + return_value=("image/jpeg", None), + ), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) + + assert result.data == "Hi there!" + + # Verify that the create stream was called with the correct parameters + # The last call should have the user message with attachments + call_args = mock_create_stream.call_args + assert call_args is not None + + # Check that the input includes the attachments + input_messages = call_args[1]["input"] + assert len(input_messages) > 0 + + # Find the user message with attachments + user_message_with_attachments = input_messages[-2] + + assert user_message_with_attachments is not None + assert isinstance(user_message_with_attachments["content"], list) + assert len(user_message_with_attachments["content"]) == 3 # Text + attachments + assert user_message_with_attachments["content"] == [ + {"type": "input_text", "text": "Test prompt"}, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + ] diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index ad5bbffaed3..3f3b7801c8f 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -8,15 +8,19 @@ from openai.types.responses import Response, ResponseOutputMessage, ResponseOutp import pytest from homeassistant import config_entries -from homeassistant.components.openai_conversation.config_flow import RECOMMENDED_OPTIONS +from homeassistant.components.openai_conversation.config_flow import ( + RECOMMENDED_CONVERSATION_OPTIONS, +) from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_VERBOSITY, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -24,12 +28,15 @@ from homeassistant.components.openai_conversation.const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_P, ) -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -72,42 +79,138 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } - assert result2["options"] == RECOMMENDED_OPTIONS + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, + ] assert len(mock_setup_entry.mock_calls) == 1 -async def test_options_recommended( +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we abort on duplicate config entry.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "bla", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"name": "My Custom Agent", **RECOMMENDED_CONVERSATION_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "My Custom Agent" + + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options + + +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_subentry_recommended( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options flow with recommended settings.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry flow with recommended settings.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + options = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { "prompt": "Speak like a pirate", "recommended": True, }, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"]["prompt"] == "Speak like a pirate" + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data["prompt"] == "Speak like a pirate" -async def test_options_unsupported_model( +async def test_subentry_unsupported_model( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form giving error about models not supported.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry form giving error about models not supported.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - assert options_flow["type"] == FlowResultType.FORM - assert options_flow["step_id"] == "init" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "init" # Configure initial step - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", @@ -115,19 +218,19 @@ async def test_options_unsupported_model( }, ) await hass.async_block_till_done() - assert options_flow["type"] == FlowResultType.FORM - assert options_flow["step_id"] == "advanced" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "advanced" # Configure advanced step - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_CHAT_MODEL: "o1-mini", }, ) await hass.async_block_till_done() - assert options_flow["type"] is FlowResultType.FORM - assert options_flow["errors"] == {"chat_model": "model_not_supported"} + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["errors"] == {"chat_model": "model_not_supported"} @pytest.mark.parametrize( @@ -195,35 +298,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "", }, ), - ( # options with no model-specific settings - {}, - ( - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - }, - { - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: "gpt-4.5-preview", - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - }, - ), - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: "gpt-4.5-preview", - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - }, - ), ( # options for reasoning models {}, ( { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT: "Speak like a pro", }, { CONF_TEMPERATURE: 1.0, @@ -233,16 +313,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT: "Speak like a pro", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "o1-pro", CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: 10000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), ( # options for web search without user location @@ -265,6 +347,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -277,6 +360,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), # Test that current options are showed as suggested values @@ -295,6 +379,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -311,6 +396,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), { @@ -323,39 +409,57 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), ( # Case 2: reasoning model { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pro", + CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o1-pro", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, - CONF_REASONING_EFFORT: "high", + CONF_REASONING_EFFORT: "low", + CONF_VERBOSITY: "high", + CONF_CODE_INTERPRETER: False, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, }, ( { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pro", + CONF_PROMPT: "Speak like a pirate", }, { CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o1-pro", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, - {CONF_REASONING_EFFORT: "high"}, + { + CONF_REASONING_EFFORT: "minimal", + CONF_CODE_INTERPRETER: False, + CONF_VERBOSITY: "high", + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, ), { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pro", + CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o1-pro", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, - CONF_REASONING_EFFORT: "high", + CONF_REASONING_EFFORT: "minimal", + CONF_CODE_INTERPRETER: False, + CONF_VERBOSITY: "high", + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, }, ), # Test that old options are removed after reconfiguration @@ -367,6 +471,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_CODE_INTERPRETER: True, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -394,10 +499,13 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-4o", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "low", + CONF_WEB_SEARCH: False, }, ( { @@ -426,6 +534,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -440,6 +549,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), { @@ -450,6 +560,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), ( # Case 4: reasoning to web search @@ -458,10 +569,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o3-mini", + CONF_CHAT_MODEL: "o5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "medium", }, ( { @@ -478,6 +591,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -490,11 +604,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), ], ) -async def test_options_switching( +async def test_subentry_switching( hass: HomeAssistant, mock_config_entry, mock_init_component, @@ -502,16 +617,22 @@ async def test_options_switching( new_options, expected_options, ) -> None: - """Test the options form.""" - hass.config_entries.async_update_entry(mock_config_entry, options=current_options) - options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert options["step_id"] == "init" + """Test the subentry form.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options + ) + await hass.async_block_till_done() + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["step_id"] == "init" for step_options in new_options: - assert options["type"] == FlowResultType.FORM + assert subentry_flow["type"] == FlowResultType.FORM # Test that current options are showed as suggested values: - for key in options["data_schema"].schema: + for key in subentry_flow["data_schema"].schema: if ( isinstance(key.description, dict) and "suggested_value" in key.description @@ -523,38 +644,42 @@ async def test_options_switching( assert key.description["suggested_value"] == current_option # Configure current step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], step_options, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + assert subentry.data == expected_options -async def test_options_web_search_user_location( +async def test_subentry_web_search_user_location( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: """Test fetching user location.""" - options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert options["type"] == FlowResultType.FORM - assert options["step_id"] == "init" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "init" # Configure initial step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", }, ) - assert options["type"] == FlowResultType.FORM - assert options["step_id"] == "advanced" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "advanced" # Configure advanced step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, @@ -563,8 +688,8 @@ async def test_options_web_search_user_location( }, ) await hass.async_block_till_done() - assert options["type"] == FlowResultType.FORM - assert options["step_id"] == "model" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "model" hass.config.country = "US" hass.config.time_zone = "America/Los_Angeles" @@ -601,8 +726,8 @@ async def test_options_web_search_user_location( ) # Configure model step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", @@ -614,8 +739,9 @@ async def test_options_web_search_user_location( mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following" " coordinates located: (37.7749, -122.4194)?" ) - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == { + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + assert subentry.data == { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 1.0, @@ -629,4 +755,124 @@ async def test_options_web_search_user_location( CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: False, + } + + +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + original ai_task + assert len(mock_config_entry.subentries) == 2 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + assert not result.get("errors") + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom AI Task", + CONF_RECOMMENDED: True, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Custom AI Task" + assert result2.get("data") == { + CONF_RECOMMENDED: True, + } + + assert ( + len(mock_config_entry.subentries) == 3 + ) # Original conversation + original ai_task + new ai_task + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.title == "Custom AI Task" + + +async def test_ai_task_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" + + +async def test_creating_ai_task_subentry_advanced( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry with advanced settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + + # Go to advanced settings + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Advanced AI Task", + CONF_RECOMMENDED: False, + }, + ) + + assert result2.get("type") is FlowResultType.FORM + assert result2.get("step_id") == "advanced" + + # Configure advanced settings + result3 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + + assert result3.get("type") is FlowResultType.FORM + assert result3.get("step_id") == "model" + + # Configure model settings + result4 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CODE_INTERPRETER: False, + }, + ) + + assert result4.get("type") is FlowResultType.CREATE_ENTRY + assert result4.get("title") == "Advanced AI Task" + assert result4.get("data") == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + CONF_CODE_INTERPRETER: False, } diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 99559cb3b61..452404f65ac 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,38 +1,13 @@ """Tests for the OpenAI integration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import httpx from openai import AuthenticationError, RateLimitError -from openai.types import ResponseFormatText from openai.types.responses import ( - Response, - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, ResponseError, ResponseErrorEvent, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionWebSearch, - ResponseIncompleteEvent, - ResponseInProgressEvent, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputText, - ResponseReasoningItem, ResponseStreamEvent, - ResponseTextConfig, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, - ResponseWebSearchCallCompletedEvent, - ResponseWebSearchCallInProgressEvent, - ResponseWebSearchCallSearchingEvent, ) from openai.types.responses.response import IncompleteDetails import pytest @@ -41,6 +16,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.openai_conversation.const import ( + CONF_CODE_INTERPRETER, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -54,6 +30,14 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent from homeassistant.setup import async_setup_component +from . import ( + create_code_interpreter_item, + create_function_tool_call_item, + create_message_item, + create_reasoning_item, + create_web_search_item, +) + from tests.common import MockConfigEntry from tests.components.conversation import ( MockChatLog, @@ -61,112 +45,24 @@ from tests.components.conversation import ( ) -@pytest.fixture -def mock_create_stream() -> Generator[AsyncMock]: - """Mock stream response.""" - - async def mock_generator(events, **kwargs): - response = Response( - id="resp_A", - created_at=1700000000, - error=None, - incomplete_details=None, - instructions=kwargs.get("instructions"), - metadata=kwargs.get("metadata", {}), - model=kwargs.get("model", "gpt-4o-mini"), - object="response", - output=[], - parallel_tool_calls=kwargs.get("parallel_tool_calls", True), - temperature=kwargs.get("temperature", 1.0), - tool_choice=kwargs.get("tool_choice", "auto"), - tools=kwargs.get("tools"), - top_p=kwargs.get("top_p", 1.0), - max_output_tokens=kwargs.get("max_output_tokens", 100000), - previous_response_id=kwargs.get("previous_response_id"), - reasoning=kwargs.get("reasoning"), - status="in_progress", - text=kwargs.get( - "text", ResponseTextConfig(format=ResponseFormatText(type="text")) - ), - truncation=kwargs.get("truncation", "disabled"), - usage=None, - user=kwargs.get("user"), - store=kwargs.get("store", True), - ) - yield ResponseCreatedEvent( - response=response, - type="response.created", - ) - yield ResponseInProgressEvent( - response=response, - type="response.in_progress", - ) - response.status = "completed" - - for value in events: - if isinstance(value, ResponseOutputItemDoneEvent): - response.output.append(value.item) - elif isinstance(value, IncompleteDetails): - response.status = "incomplete" - response.incomplete_details = value - break - if isinstance(value, ResponseError): - response.status = "failed" - response.error = value - break - - yield value - - if isinstance(value, ResponseErrorEvent): - return - - if response.status == "incomplete": - yield ResponseIncompleteEvent( - response=response, - type="response.incomplete", - ) - elif response.status == "failed": - yield ResponseFailedEvent( - response=response, - type="response.failed", - ) - else: - yield ResponseCompletedEvent( - response=response, - type="response.completed", - ) - - with patch( - "openai.resources.responses.AsyncResponses.create", - AsyncMock(), - ) as mock_create: - mock_create.side_effect = lambda **kwargs: mock_generator( - mock_create.return_value.pop(0), **kwargs - ) - - yield mock_create - - async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, ) -> None: """Test entity properties.""" - state = hass.states.get("conversation.openai") + state = hass.states.get("conversation.openai_conversation") assert state assert state.attributes["supported_features"] == 0 - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, - CONF_LLM_HASS_API: "assist", - }, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: "assist"}, ) await hass.config_entries.async_reload(mock_config_entry.entry_id) - state = hass.states.get("conversation.openai") + state = hass.states.get("conversation.openai_conversation") assert state assert ( state.attributes["supported_features"] @@ -261,7 +157,7 @@ async def test_incomplete_response( "Please tell me a big story", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -285,7 +181,7 @@ async def test_incomplete_response( "please tell me a big story", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -303,7 +199,7 @@ async def test_incomplete_response( "OpenAI response failed: Rate limit exceeded", ), ( - ResponseErrorEvent(type="error", message="Some error"), + ResponseErrorEvent(type="error", message="Some error", sequence_number=0), "OpenAI response error: Some error", ), ], @@ -324,7 +220,7 @@ async def test_failed_response( "next natural number please", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -343,203 +239,9 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -def create_message_item( - id: str, text: str | list[str], output_index: int -) -> list[ResponseStreamEvent]: - """Create a message item.""" - if isinstance(text, str): - text = [text] - - content = ResponseOutputText(annotations=[], text="", type="output_text") - events = [ - ResponseOutputItemAddedEvent( - item=ResponseOutputMessage( - id=id, - content=[], - type="message", - role="assistant", - status="in_progress", - ), - output_index=output_index, - type="response.output_item.added", - ), - ResponseContentPartAddedEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - type="response.content_part.added", - ), - ] - - content.text = "".join(text) - events.extend( - ResponseTextDeltaEvent( - content_index=0, - delta=delta, - item_id=id, - output_index=output_index, - type="response.output_text.delta", - ) - for delta in text - ) - - events.extend( - [ - ResponseTextDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - text="".join(text), - type="response.output_text.done", - ), - ResponseContentPartDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - type="response.content_part.done", - ), - ResponseOutputItemDoneEvent( - item=ResponseOutputMessage( - id=id, - content=[content], - role="assistant", - status="completed", - type="message", - ), - output_index=output_index, - type="response.output_item.done", - ), - ] - ) - - return events - - -def create_function_tool_call_item( - id: str, arguments: str | list[str], call_id: str, name: str, output_index: int -) -> list[ResponseStreamEvent]: - """Create a function tool call item.""" - if isinstance(arguments, str): - arguments = [arguments] - - events = [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="", - call_id=call_id, - name=name, - type="function_call", - status="in_progress", - ), - output_index=output_index, - type="response.output_item.added", - ) - ] - - events.extend( - ResponseFunctionCallArgumentsDeltaEvent( - delta=delta, - item_id=id, - output_index=output_index, - type="response.function_call_arguments.delta", - ) - for delta in arguments - ) - - events.append( - ResponseFunctionCallArgumentsDoneEvent( - arguments="".join(arguments), - item_id=id, - output_index=output_index, - type="response.function_call_arguments.done", - ) - ) - - events.append( - ResponseOutputItemDoneEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="".join(arguments), - call_id=call_id, - name=name, - type="function_call", - status="completed", - ), - output_index=output_index, - type="response.output_item.done", - ) - ) - - return events - - -def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a reasoning item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - ), - output_index=output_index, - type="response.output_item.added", - ), - ResponseOutputItemDoneEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - ), - output_index=output_index, - type="response.output_item.done", - ), - ] - - -def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a web search call item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionWebSearch( - id=id, status="in_progress", type="web_search_call" - ), - output_index=output_index, - type="response.output_item.added", - ), - ResponseWebSearchCallInProgressEvent( - item_id=id, - output_index=output_index, - type="response.web_search_call.in_progress", - ), - ResponseWebSearchCallSearchingEvent( - item_id=id, - output_index=output_index, - type="response.web_search_call.searching", - ), - ResponseWebSearchCallCompletedEvent( - item_id=id, - output_index=output_index, - type="response.web_search_call.completed", - ), - ResponseOutputItemDoneEvent( - item=ResponseFunctionWebSearch( - id=id, status="completed", type="web_search_call" - ), - output_index=output_index, - type="response.output_item.done", - ), - ] - - async def test_function_call( hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, + mock_config_entry_with_reasoning_model: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 @@ -550,7 +252,11 @@ async def test_function_call( # Initial conversation ( # Wait for the model to think - *create_reasoning_item(id="rs_A", output_index=0), + *create_reasoning_item( + id="rs_A", + output_index=0, + reasoning_summary=[["Thinking"], ["Thinking ", "more"]], + ), # First tool call *create_function_tool_call_item( id="fc_1", @@ -583,17 +289,13 @@ async def test_function_call( "Please call the test function", mock_chat_log.conversation_id, Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) - assert mock_create_stream.call_args.kwargs["input"][2] == { - "id": "rs_A", - "summary": [], - "type": "reasoning", - } assert result.response.response_type == intent.IntentResponseType.ACTION_DONE # Don't test the prompt, as it's not deterministic assert mock_chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["input"][1:] == snapshot async def test_function_call_without_reasoning( @@ -630,7 +332,7 @@ async def test_function_call_without_reasoning( "Please call the test function", mock_chat_log.conversation_id, Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE @@ -686,7 +388,7 @@ async def test_function_call_invalid( "Please call the test function", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) @@ -720,7 +422,7 @@ async def test_assist_api_tools_conversion( ] await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.openai" + hass, "hello", None, Context(), agent_id="conversation.openai_conversation" ) tools = mock_create_stream.mock_calls[0][2]["tools"] @@ -733,12 +435,15 @@ async def test_web_search( mock_init_component, mock_create_stream, mock_chat_log: MockChatLog, # noqa: F811 + snapshot: SnapshotAssertion, ) -> None: """Test web_search_tool.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -764,7 +469,7 @@ async def test_web_search( "What's on the latest news?", mock_chat_log.conversation_id, Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert mock_create_stream.mock_calls[0][2]["tools"] == [ @@ -782,3 +487,81 @@ async def test_web_search( ] assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.speech["plain"]["speech"] == message, result.response.speech + + # Test follow-up message in multi-turn conversation + mock_create_stream.return_value = [ + (*create_message_item(id="msg_B", text="You are welcome!", output_index=1),) + ] + + result = await conversation.async_converse( + hass, + "Thank you!", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai_conversation", + ) + + assert mock_create_stream.mock_calls[1][2]["input"][1:] == snapshot + + +async def test_code_interpreter( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream, + mock_chat_log: MockChatLog, # noqa: F811 + snapshot: SnapshotAssertion, +) -> None: + """Test code_interpreter tool.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + **subentry.data, + CONF_CODE_INTERPRETER: True, + }, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + message = "I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758." + mock_create_stream.return_value = [ + ( + *create_code_interpreter_item( + id="ci_A", + code=["import", " math", "\n", "math", ".sqrt", "(", "555", "55", ")"], + logs="235.70108188126758\n", + output_index=0, + ), + *create_message_item(id="msg_A", text=message, output_index=1), + ) + ] + + result = await conversation.async_converse( + hass, + "Please use the python tool to calculate square root of 55555", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai_conversation", + ) + + assert mock_create_stream.mock_calls[0][2]["tools"] == [ + {"type": "code_interpreter", "container": {"type": "auto"}} + ] + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == message, result.response.speech + + # Test follow-up message in multi-turn conversation + mock_create_stream.return_value = [ + (*create_message_item(id="msg_B", text="You are welcome!", output_index=1),) + ] + + result = await conversation.async_converse( + hass, + "Thank you!", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai_conversation", + ) + + assert mock_create_stream.mock_calls[1][2]["input"][1:] == snapshot diff --git a/tests/components/openai_conversation/test_entity.py b/tests/components/openai_conversation/test_entity.py new file mode 100644 index 00000000000..c24cb5b3d79 --- /dev/null +++ b/tests/components/openai_conversation/test_entity.py @@ -0,0 +1,79 @@ +"""Tests for the OpenAI Conversation entity.""" + +import voluptuous as vol + +from homeassistant.components.openai_conversation.entity import ( + _format_structured_output, +) +from homeassistant.helpers import selector + + +async def test_format_structured_output() -> None: + """Test the format_structured_output function.""" + schema = vol.Schema( + { + vol.Required("name"): selector.TextSelector(), + vol.Optional("age"): selector.NumberSelector( + config=selector.NumberSelectorConfig( + min=0, + max=120, + ), + ), + vol.Required("stuff"): selector.ObjectSelector( + { + "multiple": True, + "fields": { + "item_name": { + "selector": {"text": None}, + }, + "item_value": { + "selector": {"text": None}, + }, + }, + } + ), + } + ) + assert _format_structured_output(schema, None) == { + "additionalProperties": False, + "properties": { + "age": { + "maximum": 120.0, + "minimum": 0.0, + "type": [ + "number", + "null", + ], + }, + "name": { + "type": "string", + }, + "stuff": { + "items": { + "properties": { + "item_name": { + "type": ["string", "null"], + }, + "item_value": { + "type": ["string", "null"], + }, + }, + "required": [ + "item_name", + "item_value", + ], + "type": "object", + "additionalProperties": False, + "strict": True, + }, + "type": "array", + }, + }, + "required": [ + "name", + "stuff", + "age", + ], + "strict": True, + "type": "object", + } diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index b4f816707e9..66afc41826b 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,6 +1,7 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, mock_open, patch +from typing import Any +from unittest.mock import AsyncMock, Mock, mock_open, patch import httpx from openai import ( @@ -13,10 +14,24 @@ from openai.types.image import Image from openai.types.images_response import ImagesResponse from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.openai_conversation import CONF_FILENAMES +from homeassistant.components.openai_conversation import CONF_CHAT_MODEL +from homeassistant.components.openai_conversation.const import ( + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, + DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, +) +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -79,6 +94,7 @@ async def test_generate_image_service( with patch( "openai.resources.images.AsyncImages.generate", + new_callable=AsyncMock, return_value=ImagesResponse( created=1700000000, data=[ @@ -115,6 +131,7 @@ async def test_generate_image_service_error( with ( patch( "openai.resources.images.AsyncImages.generate", + new_callable=AsyncMock, side_effect=RateLimitError( response=httpx.Response( status_code=500, request=httpx.Request(method="GET", url="") @@ -139,6 +156,7 @@ async def test_generate_image_service_error( with ( patch( "openai.resources.images.AsyncImages.generate", + new_callable=AsyncMock, return_value=ImagesResponse( created=1700000000, data=[ @@ -373,7 +391,7 @@ async def test_generate_content_service( """Test generate content service.""" service_data["config_entry"] = mock_config_entry.entry_id expected_args["model"] = "gpt-4o-mini" - expected_args["max_output_tokens"] = 150 + expected_args["max_output_tokens"] = 3000 expected_args["top_p"] = 1.0 expected_args["temperature"] = 1.0 expected_args["user"] = None @@ -389,7 +407,7 @@ async def test_generate_content_service( patch( "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] ) as mock_b64encode, - patch("builtins.open", mock_open(read_data="ABC")) as mock_file, + patch("pathlib.Path.read_bytes", Mock(return_value=b"ABC")) as mock_file, patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), ): @@ -429,15 +447,13 @@ async def test_generate_content_service( assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][2] == expected_args assert mock_b64encode.call_count == number_of_files - for idx, file in enumerate(service_data[CONF_FILENAMES]): - assert mock_file.call_args_list[idx][0][0] == file + assert mock_file.call_count == number_of_files @pytest.mark.parametrize( ( "service_data", "error", - "number_of_files", "exists_side_effect", "is_allowed_side_effect", ), @@ -445,7 +461,6 @@ async def test_generate_content_service( ( {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, "`/a/b/c.jpg` does not exist", - 0, [False], [True], ), @@ -455,14 +470,12 @@ async def test_generate_content_service( "filenames": ["/a/b/c.jpg", "d/e/f.png"], }, "Cannot read `d/e/f.png`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`", - 1, [True, True], [True, False], ), ( {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.mov"]}, "Only images and PDF are supported by the OpenAI API,`/a/b/c.mov` is not an image file or PDF", - 1, [True], [True], ), @@ -474,7 +487,6 @@ async def test_generate_content_service_invalid( mock_init_component, service_data, error, - number_of_files, exists_side_effect, is_allowed_side_effect, ) -> None: @@ -486,9 +498,7 @@ async def test_generate_content_service_invalid( "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, ) as mock_create, - patch( - "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] - ) as mock_b64encode, + patch("base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"]), patch("builtins.open", mock_open(read_data="ABC")), patch("pathlib.Path.exists", side_effect=exists_side_effect), patch.object( @@ -504,7 +514,6 @@ async def test_generate_content_service_invalid( return_response=True, ) assert len(mock_create.mock_calls) == 0 - assert mock_b64encode.call_count == number_of_files @pytest.mark.usefixtures("mock_init_component") @@ -536,3 +545,976 @@ async def test_generate_content_service_error( blocking=True, return_response=True, ) + + +async def test_migration_from_v1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + OPTIONS = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=OPTIONS, + version=1, + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="chatgpt", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 4 + assert mock_config_entry.data == {"api_key": "1234"} + assert mock_config_entry.options == {} + + assert len(mock_config_entry.subentries) == 2 + + # Find the conversation subentry + conversation_subentry = None + ai_task_subentry = None + for subentry in mock_config_entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + elif subentry.subentry_type == "ai_task_data": + ai_task_subentry = subentry + assert conversation_subentry is not None + assert conversation_subentry.unique_id is None + assert conversation_subentry.title == "ChatGPT" + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == OPTIONS + + assert ai_task_subentry is not None + assert ai_task_subentry.unique_id is None + assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME + assert ai_task_subentry.subentry_type == "ai_task_data" + + # Use conversation subentry for the rest of the assertions + subentry = conversation_subentry + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.unique_id == subentry.subentry_id + + # Check device migration + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + migrated_device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +async def test_migration_from_v1_with_multiple_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 with different API keys.""" + # Create two v1 config entries with different API keys + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="ChatGPT 1", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "12345"}, + options=options, + version=1, + title="ChatGPT 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT 1", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="chatgpt_1", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT 2", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for idx, entry in enumerate(entries): + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert len(entry.subentries) == 2 + + conversation_subentry = None + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + break + + assert conversation_subentry is not None + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == options + assert conversation_subentry.title == f"ChatGPT {idx + 1}" + + # Use conversation subentry for device assertions + subentry = conversation_subentry + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} + + +async def test_migration_from_v1_with_same_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 with same API keys consolidates entries.""" + # Create two v1 config entries with the same API key + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, # Same API key + options=options, + version=1, + title="ChatGPT 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="chatgpt", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Should have only one entry left (consolidated) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert ( + len(entry.subentries) == 3 + ) # Two conversation subentries + one AI task subentry + + # Check both conversation subentries exist with correct data + conversation_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "conversation" + ] + ai_task_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "ai_task_data" + ] + + assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 + + titles = [sub.title for sub in conversation_subentries] + assert "ChatGPT" in titles + assert "ChatGPT 2" in titles + + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + + # Check devices were migrated correctly + dev = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="chatgpt", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == "OpenAI Conversation" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + +async def test_migration_from_v2_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="ChatGPT 2", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="ChatGPT", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="chatgpt", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="ChatGPT 2", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 3 # 2 conversation + 1 AI task + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.chatgpt") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.chatgpt_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + +@pytest.mark.parametrize( + "mock_conversation_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}] +) +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test devices are correctly created for subentries.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 2 # One for conversation, one for AI task + + # Use the first device for snapshot comparison + device = devices[0] + assert device == snapshot(exclude=props("identifiers")) + # Verify the device has identifiers matching one of the subentries + expected_identifiers = [ + {(DOMAIN, subentry.subentry_id)} + for subentry in mock_config_entry.subentries.values() + ] + assert device.identifiers in expected_identifiers + + +async def test_migration_from_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.2.""" + # Create a v2.2 config entry with a conversation subentry + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=2, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 2 + + # Check conversation subentry is still there + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 1 + conversation_subentry = conversation_subentries[0] + assert conversation_subentry.data == options + + # Check AI Task subentry was added + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + ai_task_subentry = ai_task_subentries[0] + assert ai_task_subentry.data == {"recommended": True} + assert ai_task_subentry.title == "OpenAI AI Task" + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 4, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 3, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 2.3.""" + # Create a v2.3 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=3, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="chatgpt", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration diff --git a/tests/components/openweathermap/conftest.py b/tests/components/openweathermap/conftest.py index f7de53b8f97..7c7de776acf 100644 --- a/tests/components/openweathermap/conftest.py +++ b/tests/components/openweathermap/conftest.py @@ -77,8 +77,8 @@ def owm_client_mock() -> Generator[AsyncMock]: cloud_coverage=75, visibility=10000, wind_speed=9.83, + wind_gust=11.81, wind_bearing=199, - wind_gust=None, rain={"1h": 1.21}, snow=None, condition=WeatherCondition( diff --git a/tests/components/openweathermap/snapshots/test_sensor.ambr b/tests/components/openweathermap/snapshots/test_sensor.ambr index 11a1feb721f..ae80431f33c 100644 --- a/tests/components/openweathermap/snapshots/test_sensor.ambr +++ b/tests/components/openweathermap/snapshots/test_sensor.ambr @@ -86,7 +86,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-co', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_carbon_monoxide-state] @@ -96,7 +96,7 @@ 'device_class': 'carbon_monoxide', 'friendly_name': 'openweathermap Carbon monoxide', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_carbon_monoxide', @@ -140,7 +140,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-no2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_nitrogen_dioxide-state] @@ -150,7 +150,7 @@ 'device_class': 'nitrogen_dioxide', 'friendly_name': 'openweathermap Nitrogen dioxide', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_nitrogen_dioxide', @@ -194,7 +194,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-no', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_nitrogen_monoxide-state] @@ -204,7 +204,7 @@ 'device_class': 'nitrogen_monoxide', 'friendly_name': 'openweathermap Nitrogen monoxide', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_nitrogen_monoxide', @@ -248,7 +248,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-o3', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_ozone-state] @@ -258,7 +258,7 @@ 'device_class': 'ozone', 'friendly_name': 'openweathermap Ozone', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_ozone', @@ -302,7 +302,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-pm10', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_pm10-state] @@ -312,7 +312,7 @@ 'device_class': 'pm10', 'friendly_name': 'openweathermap PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_pm10', @@ -356,7 +356,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-pm2_5', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_pm2_5-state] @@ -366,7 +366,7 @@ 'device_class': 'pm25', 'friendly_name': 'openweathermap PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_pm2_5', @@ -410,7 +410,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-so2', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_sulphur_dioxide-state] @@ -420,7 +420,7 @@ 'device_class': 'sulphur_dioxide', 'friendly_name': 'openweathermap Sulphur dioxide', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_sulphur_dioxide', @@ -1239,6 +1239,66 @@ 'state': '199', }) # --- +# name: test_sensor_states[current][sensor.openweathermap_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.516', + }) +# --- # name: test_sensor_states[current][sensor.openweathermap_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2108,6 +2168,66 @@ 'state': '199', }) # --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.516', + }) +# --- # name: test_sensor_states[v3.0][sensor.openweathermap_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index 760160a96f4..073715c87ec 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -74,6 +74,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), @@ -137,6 +138,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), @@ -200,6 +202,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), diff --git a/tests/components/opower/conftest.py b/tests/components/opower/conftest.py index 12d1a0dcdce..ea1fc5e1e37 100644 --- a/tests/components/opower/conftest.py +++ b/tests/components/opower/conftest.py @@ -1,5 +1,11 @@ """Fixtures for the Opower integration tests.""" +from collections.abc import Generator +from datetime import date +from unittest.mock import AsyncMock, Mock, patch + +from opower import Account, Forecast, MeterType, ReadResolution, UnitOfMeasure +from opower.utilities.pge import PGE import pytest from homeassistant.components.opower.const import DOMAIN @@ -22,3 +28,76 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture +def mock_opower_api() -> Generator[AsyncMock]: + """Mock Opower API.""" + with patch( + "homeassistant.components.opower.coordinator.Opower", autospec=True + ) as mock_api: + api = mock_api.return_value + api.utility = PGE + + api.async_get_accounts.return_value = [ + Account( + customer=Mock(), + uuid="111111-uuid", + utility_account_id="111111", + id="111111", + meter_type=MeterType.ELEC, + read_resolution=ReadResolution.HOUR, + ), + Account( + customer=Mock(), + uuid="222222-uuid", + utility_account_id="222222", + id="222222", + meter_type=MeterType.GAS, + read_resolution=ReadResolution.DAY, + ), + ] + api.async_get_forecast.return_value = [ + Forecast( + account=Account( + customer=Mock(), + uuid="111111-uuid", + utility_account_id="111111", + id="111111", + meter_type=MeterType.ELEC, + read_resolution=ReadResolution.HOUR, + ), + usage_to_date=100, + cost_to_date=20.0, + forecasted_usage=200, + forecasted_cost=40.0, + typical_usage=180, + typical_cost=36.0, + unit_of_measure=UnitOfMeasure.KWH, + start_date=date(2023, 1, 1), + end_date=date(2023, 1, 31), + current_date=date(2023, 1, 15), + ), + Forecast( + account=Account( + customer=Mock(), + uuid="222222-uuid", + utility_account_id="222222", + id="222222", + meter_type=MeterType.GAS, + read_resolution=ReadResolution.DAY, + ), + usage_to_date=50, + cost_to_date=15.0, + forecasted_usage=100, + forecasted_cost=30.0, + typical_usage=90, + typical_cost=27.0, + unit_of_measure=UnitOfMeasure.CCF, + start_date=date(2023, 1, 1), + end_date=date(2023, 1, 31), + current_date=date(2023, 1, 15), + ), + ] + api.async_get_cost_reads.return_value = [] + yield api diff --git a/tests/components/opower/snapshots/test_coordinator.ambr b/tests/components/opower/snapshots/test_coordinator.ambr new file mode 100644 index 00000000000..afa93c5bcf4 --- /dev/null +++ b/tests/components/opower/snapshots/test_coordinator.ambr @@ -0,0 +1,177 @@ +# serializer version: 1 +# name: test_coordinator_first_run + defaultdict({ + 'opower:pge_elec_111111_energy_compensation': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.1, + 'sum': 0.1, + }), + ]), + 'opower:pge_elec_111111_energy_consumption': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 1.5, + 'sum': 1.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 1.5, + }), + ]), + 'opower:pge_elec_111111_energy_cost': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.5, + 'sum': 0.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 0.5, + }), + ]), + 'opower:pge_elec_111111_energy_return': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.5, + 'sum': 0.5, + }), + ]), + }) +# --- +# name: test_coordinator_migration + defaultdict({ + 'opower:pge_elec_111111_energy_consumption': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 1.5, + 'sum': 1.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 1.5, + }), + ]), + 'opower:pge_elec_111111_energy_return': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.5, + 'sum': 0.5, + }), + ]), + }) +# --- +# name: test_coordinator_subsequent_run + defaultdict({ + 'opower:pge_elec_111111_energy_compensation': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.1, + 'sum': 0.1, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 0.0, + 'sum': 0.1, + }), + ]), + 'opower:pge_elec_111111_energy_consumption': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 1.5, + 'sum': 1.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 1.5, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 2.0, + 'sum': 3.5, + }), + ]), + 'opower:pge_elec_111111_energy_cost': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.5, + 'sum': 0.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 0.5, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 0.7, + 'sum': 1.2, + }), + ]), + 'opower:pge_elec_111111_energy_return': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.5, + 'sum': 0.5, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 0.0, + 'sum': 0.5, + }), + ]), + }) +# --- diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 8134539b0a5..4e5c3457fa6 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from opower import CannotConnect, InvalidAuth +from opower import CannotConnect, InvalidAuth, MfaChallenge import pytest from homeassistant import config_entries @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.fixture(autouse=True, name="mock_setup_entry") @@ -43,24 +43,32 @@ async def test_form( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "credentials" + + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" - assert result2["data"] == { + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" + assert result3["data"] == { "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", @@ -69,33 +77,33 @@ async def test_form( assert mock_login.call_count == 1 -async def test_form_with_mfa( +async def test_form_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test we can configure a utility that accepts a TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { + "username": "test-username", + "password": "test-password", "totp_secret": "test-totp", }, ) @@ -112,43 +120,42 @@ async def test_form_with_mfa( assert mock_login.call_count == 1 -async def test_form_with_mfa_bad_secret( +async def test_form_with_invalid_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test MFA asks for password again when validation fails.""" + """Test we handle an invalid TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter invalid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", side_effect=InvalidAuth, - ) as mock_login: + ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "totp_secret": "test-totp", + "username": "test-username", + "password": "test-password", + "totp_secret": "bad-totp", }, ) assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "base": "invalid_auth", - } + assert result3["errors"] == {"base": "invalid_auth"} + assert result3["step_id"] == "credentials" + # Enter valid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -157,7 +164,7 @@ async def test_form_with_mfa_bad_secret( { "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", }, ) @@ -167,26 +174,195 @@ async def test_form_with_mfa_bad_secret( "utility": "Consolidated Edison (ConEd)", "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", } assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 +async def test_form_with_mfa_challenge( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow, including error recovery.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. Handle the MFA options step, starting with a connection error + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + # Test CannotConnect on selecting MFA method + mock_mfa_handler.async_select_mfa_option.side_effect = CannotConnect + result_mfa_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Email") + assert result_mfa_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_connect_fail["step_id"] == "mfa_options" + assert result_mfa_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry selecting MFA method successfully + mock_mfa_handler.async_select_mfa_option.side_effect = None + result_mfa_select_ok = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + assert mock_mfa_handler.async_select_mfa_option.call_count == 2 + assert result_mfa_select_ok["type"] is FlowResultType.FORM + assert result_mfa_select_ok["step_id"] == "mfa_code" + + # 4. Handle the MFA code step, testing multiple failure scenarios + # Test InvalidAuth on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = InvalidAuth + result_mfa_invalid_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "bad-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("bad-code") + assert result_mfa_invalid_code["type"] is FlowResultType.FORM + assert result_mfa_invalid_code["step_id"] == "mfa_code" + assert result_mfa_invalid_code["errors"] == {"base": "invalid_mfa_code"} + + # Test CannotConnect on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = CannotConnect + result_mfa_code_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 2 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + assert result_mfa_code_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_code_connect_fail["step_id"] == "mfa_code" + assert result_mfa_code_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry submitting code successfully + mock_mfa_handler.async_submit_mfa_code.side_effect = None + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 3 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 5. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_mfa_challenge_but_no_mfa_options( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow when there are no MFA options.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = {} + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. No MFA options. Handle the MFA code step + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_code" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 4. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.parametrize( ("api_exception", "expected_error"), [ - (InvalidAuth(), "invalid_auth"), - (CannotConnect(), "cannot_connect"), + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), ], ) async def test_form_exceptions( - recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error + recorder_mock: Recorder, + hass: HomeAssistant, + api_exception: Exception, + expected_error: str, ) -> None: """Test we handle exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -195,7 +371,6 @@ async def test_form_exceptions( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -203,6 +378,10 @@ async def test_form_exceptions( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": expected_error} + # On error, the form should have the previous user input as suggested values. + data_schema = result2["data_schema"].schema + assert get_schema_suggested_value(data_schema, "username") == "test-username" + assert get_schema_suggested_value(data_schema, "password") == "test-password" assert mock_login.call_count == 1 @@ -215,6 +394,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -222,7 +405,6 @@ async def test_form_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -243,6 +425,10 @@ async def test_form_not_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -250,7 +436,6 @@ async def test_form_not_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username2", "password": "test-password", }, @@ -290,6 +475,16 @@ async def test_form_valid_reauth( assert result["context"]["source"] == "reauth" assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title} + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -312,22 +507,23 @@ async def test_form_valid_reauth( assert mock_login.call_count == 1 -async def test_form_valid_reauth_with_mfa( +async def test_form_valid_reauth_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_unload_entry: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: - """Test that we can handle a valid reauth.""" - hass.config_entries.async_update_entry( - mock_config_entry, + """Test that we can handle a valid reauth for a utility with TOTP.""" + mock_config_entry = MockConfigEntry( + title="Consolidated Edison (ConEd) (test-username)", + domain=DOMAIN, data={ - **mock_config_entry.data, - # Requires MFA "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password", }, ) + mock_config_entry.add_to_hass(hass) mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) @@ -337,6 +533,17 @@ async def test_form_valid_reauth_with_mfa( assert len(flows) == 1 result = flows[0] + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + "totp_secret", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -362,3 +569,109 @@ async def test_form_valid_reauth_with_mfa( assert len(mock_unload_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 + + +async def test_reauth_with_mfa_challenge( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full interactive MFA flow during reauth.""" + # 1. Set up the existing entry and trigger reauth + mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # 2. Test failure before MFA challenge (InvalidAuth) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ) as mock_login_fail_auth: + result_invalid_auth = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "bad-password", + }, + ) + mock_login_fail_auth.assert_awaited_once() + assert result_invalid_auth["type"] is FlowResultType.FORM + assert result_invalid_auth["step_id"] == "reauth_confirm" + assert result_invalid_auth["errors"] == {"base": "invalid_auth"} + + # 3. Test failure before MFA challenge (CannotConnect) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=CannotConnect, + ) as mock_login_fail_connect: + result_cannot_connect = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_fail_connect.assert_awaited_once() + assert result_cannot_connect["type"] is FlowResultType.FORM + assert result_cannot_connect["step_id"] == "reauth_confirm" + assert result_cannot_connect["errors"] == {"base": "cannot_connect"} + + # 4. Trigger the MfaChallenge on the next attempt + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login_mfa: + result_mfa_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_mfa.assert_awaited_once() + + # 5. Handle the happy path for the MFA flow + assert result_mfa_challenge["type"] is FlowResultType.FORM + assert result_mfa_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + result_mfa_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Phone"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Phone") + assert result_mfa_code["type"] is FlowResultType.FORM + assert result_mfa_code["step_id"] == "mfa_code" + + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("good-code") + + # 6. Verify the reauth completes successfully + assert result_final["type"] is FlowResultType.ABORT + assert result_final["reason"] == "reauth_successful" + await hass.async_block_till_done() + + # Check that data was updated and the entry was reloaded + assert mock_config_entry.data["password"] == "new-password" + assert mock_config_entry.data["login_data"] == { + "login_data_mock_key": "login_data_mock_value" + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/opower/test_coordinator.py b/tests/components/opower/test_coordinator.py new file mode 100644 index 00000000000..5f55fd481ba --- /dev/null +++ b/tests/components/opower/test_coordinator.py @@ -0,0 +1,236 @@ +"""Tests for the Opower coordinator.""" + +from datetime import datetime +from unittest.mock import AsyncMock + +from opower import CostRead +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.opower.const import DOMAIN +from homeassistant.components.opower.coordinator import OpowerCoordinator +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry +from tests.components.recorder.common import async_wait_recording_done + + +async def test_coordinator_first_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator on its first run with no existing statistics.""" + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=1.5, + provided_cost=0.5, + ), + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=-0.5, # Grid return + provided_cost=-0.1, # Compensation + ), + ] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + + await async_wait_recording_done(hass) + + # Check stats for electric account '111111' + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + "opower:pge_elec_111111_energy_cost", + "opower:pge_elec_111111_energy_compensation", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + +async def test_coordinator_subsequent_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator correctly updates statistics on subsequent runs.""" + # First run + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=1.5, + provided_cost=0.5, + ), + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=-0.5, + provided_cost=-0.1, + ), + ] + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Second run with updated data for one hour and new data for the next hour + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), # Updated data + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=-1.0, # Was -0.5 + provided_cost=-0.2, # Was -0.1 + ), + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), # New data + end_time=dt_util.as_utc(datetime(2023, 1, 1, 11)), + consumption=2.0, + provided_cost=0.7, + ), + ] + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Check all stats + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + "opower:pge_elec_111111_energy_cost", + "opower:pge_elec_111111_energy_compensation", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + +async def test_coordinator_subsequent_run_no_energy_data( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator handles no recent usage/cost data.""" + # First run + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=1.5, + provided_cost=0.5, + ), + ] + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Second run with no data + mock_opower_api.async_get_cost_reads.return_value = [] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + + assert "No recent usage/cost data. Skipping update" in caplog.text + + # Verify no new stats were added by checking the sum remains 1.5 + statistic_id = "opower:pge_elec_111111_energy_consumption" + stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True, {"sum"} + ) + assert stats[statistic_id][0]["sum"] == 1.5 + + +async def test_coordinator_migration( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the one-time migration for return-to-grid statistics.""" + # Setup: Create old-style consumption data with negative values + statistic_id = "opower:pge_elec_111111_energy_consumption" + metadata = StatisticMetaData( + has_sum=True, + name="Opower pge elec 111111 consumption", + source=DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + statistics_to_add = [ + StatisticData( + start=dt_util.as_utc(datetime(2023, 1, 1, 8)), + state=1.5, + sum=1.5, + ), + StatisticData( + start=dt_util.as_utc(datetime(2023, 1, 1, 9)), + state=-0.5, # This should be migrated + sum=1.0, + ), + ] + async_add_external_statistics(hass, metadata, statistics_to_add) + await async_wait_recording_done(hass) + + # When the coordinator runs, it should trigger the migration + # Don't need new cost reads for this test + mock_opower_api.async_get_cost_reads.return_value = [] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Check that the stats have been migrated + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + # Check that an issue was created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "return_to_grid_migration_111111") + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING diff --git a/tests/components/opower/test_init.py b/tests/components/opower/test_init.py new file mode 100644 index 00000000000..042dd42b0cf --- /dev/null +++ b/tests/components/opower/test_init.py @@ -0,0 +1,116 @@ +"""Tests for the Opower integration.""" + +from unittest.mock import AsyncMock + +from opower.exceptions import ApiException, CannotConnect, InvalidAuth +import pytest + +from homeassistant.components.opower.const import DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_unload_entry( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test successful setup and unload of a config entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_opower_api.async_login.assert_awaited_once() + mock_opower_api.async_get_forecast.assert_awaited_once() + mock_opower_api.async_get_accounts.assert_awaited_once() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +@pytest.mark.parametrize( + ("login_side_effect", "expected_state"), + [ + ( + CannotConnect(), + ConfigEntryState.SETUP_RETRY, + ), + ( + InvalidAuth(), + ConfigEntryState.SETUP_ERROR, + ), + ], +) +async def test_login_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + login_side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test for login error.""" + mock_opower_api.async_login.side_effect = login_side_effect + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +async def test_get_forecast_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test for API error when getting forecast.""" + mock_opower_api.async_get_forecast.side_effect = ApiException( + message="forecast error", url="" + ) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_accounts_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test for API error when getting accounts.""" + mock_opower_api.async_get_accounts.side_effect = ApiException( + message="accounts error", url="" + ) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_cost_reads_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test for API error when getting cost reads.""" + mock_opower_api.async_get_cost_reads.side_effect = ApiException( + message="cost reads error", url="" + ) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/opower/test_repairs.py b/tests/components/opower/test_repairs.py new file mode 100644 index 00000000000..7f589be6a26 --- /dev/null +++ b/tests/components/opower/test_repairs.py @@ -0,0 +1,82 @@ +"""Test the Opower repairs.""" + +from homeassistant.components.opower.const import DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_unsupported_utility_fix_flow( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the unsupported utility fix flow.""" + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "utility": "Unsupported Utility", + "username": "test-user", + "password": "test-password", + }, + title="My Unsupported Utility", + ) + mock_config_entry.add_to_hass(hass) + + # Setting up the component with an unsupported utility should fail and create an issue + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + # Verify the issue was created correctly + issue_id = f"unsupported_utility_{mock_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "unsupported_utility" + assert issue.is_fixable is True + assert issue.data == { + "entry_id": mock_config_entry.entry_id, + "utility": "Unsupported Utility", + "title": "My Unsupported Utility", + } + + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + # Start the repair flow + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) + flow_id = data["flow_id"] + + # The flow should go directly to the confirm step + assert data["step_id"] == "confirm" + assert data["description_placeholders"] == { + "utility": "Unsupported Utility", + "title": "My Unsupported Utility", + } + + # Submit the confirmation form + data = await process_repair_fix_flow(http_client, flow_id, json={}) + + # The flow should complete and create an empty entry, signaling success + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + # Check that the config entry has been removed + assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) is None + # Check that the issue has been resolved + assert not issue_registry.async_get_issue(DOMAIN, issue_id) diff --git a/tests/components/opower/test_sensor.py b/tests/components/opower/test_sensor.py new file mode 100644 index 00000000000..883bf86f883 --- /dev/null +++ b/tests/components/opower/test_sensor.py @@ -0,0 +1,72 @@ +"""Tests for the Opower sensor platform.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.recorder import Recorder +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensors( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test the creation and values of Opower sensors.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + # Check electric sensors + entry = entity_registry.async_get( + "sensor.elec_account_111111_current_bill_electric_usage_to_date" + ) + assert entry + assert entry.unique_id == "pge_111111_elec_usage_to_date" + state = hass.states.get( + "sensor.elec_account_111111_current_bill_electric_usage_to_date" + ) + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + assert state.state == "100" + + entry = entity_registry.async_get( + "sensor.elec_account_111111_current_bill_electric_cost_to_date" + ) + assert entry + assert entry.unique_id == "pge_111111_elec_cost_to_date" + state = hass.states.get( + "sensor.elec_account_111111_current_bill_electric_cost_to_date" + ) + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" + assert state.state == "20.0" + + # Check gas sensors + entry = entity_registry.async_get( + "sensor.gas_account_222222_current_bill_gas_usage_to_date" + ) + assert entry + assert entry.unique_id == "pge_222222_gas_usage_to_date" + state = hass.states.get("sensor.gas_account_222222_current_bill_gas_usage_to_date") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS + # Convert 50 CCF to m³ + assert float(state.state) == pytest.approx(50 * 2.83168, abs=1e-3) + + entry = entity_registry.async_get( + "sensor.gas_account_222222_current_bill_gas_cost_to_date" + ) + assert entry + assert entry.unique_id == "pge_222222_gas_cost_to_date" + state = hass.states.get("sensor.gas_account_222222_current_bill_gas_cost_to_date") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" + assert state.state == "15.0" diff --git a/tests/components/osoenergy/conftest.py b/tests/components/osoenergy/conftest.py index bb14fec0241..915761ba6d3 100644 --- a/tests/components/osoenergy/conftest.py +++ b/tests/components/osoenergy/conftest.py @@ -74,6 +74,8 @@ async def mock_osoenergy_client(mock_water_heater) -> Generator[AsyncMock]: mock_client().session = mock_session mock_hotwater = MagicMock() + mock_hotwater.enable_holiday_mode = AsyncMock(return_value=True) + mock_hotwater.disable_holiday_mode = AsyncMock(return_value=True) mock_hotwater.get_water_heater = AsyncMock(return_value=mock_water_heater) mock_hotwater.set_profile = AsyncMock(return_value=True) mock_hotwater.set_v40_min = AsyncMock(return_value=True) diff --git a/tests/components/osoenergy/fixtures/water_heater.json b/tests/components/osoenergy/fixtures/water_heater.json index 82bdafb5d8a..4c2b7abbb41 100644 --- a/tests/components/osoenergy/fixtures/water_heater.json +++ b/tests/components/osoenergy/fixtures/water_heater.json @@ -16,5 +16,6 @@ "profile": [ 10, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60 - ] + ], + "isInPowerSave": false } diff --git a/tests/components/osoenergy/snapshots/test_water_heater.ambr b/tests/components/osoenergy/snapshots/test_water_heater.ambr index 18c434d133b..208fd3b2aa3 100644 --- a/tests/components/osoenergy/snapshots/test_water_heater.ambr +++ b/tests/components/osoenergy/snapshots/test_water_heater.ambr @@ -31,7 +31,7 @@ 'platform': 'osoenergy', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'osoenergy_water_heater', 'unit_of_measurement': None, @@ -40,11 +40,12 @@ # name: test_water_heater[water_heater.test_device-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'away_mode': 'off', 'current_temperature': 60, 'friendly_name': 'TEST DEVICE', 'max_temp': 75, 'min_temp': 10, - 'supported_features': , + 'supported_features': , 'target_temp_high': 63, 'target_temp_low': 57, 'temperature': 60, diff --git a/tests/components/osoenergy/test_water_heater.py b/tests/components/osoenergy/test_water_heater.py index fd27975c938..dd3a08dd24f 100644 --- a/tests/components/osoenergy/test_water_heater.py +++ b/tests/components/osoenergy/test_water_heater.py @@ -7,14 +7,18 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.osoenergy.const import DOMAIN from homeassistant.components.osoenergy.water_heater import ( + ATTR_DURATION_DAYS, ATTR_UNTIL_TEMP_LIMIT, ATTR_V40MIN, SERVICE_GET_PROFILE, SERVICE_SET_PROFILE, SERVICE_SET_V40MIN, + SERVICE_TURN_AWAY_MODE_ON, ) from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, SERVICE_SET_TEMPERATURE, ) from homeassistant.config_entries import ConfigEntry @@ -274,3 +278,59 @@ async def test_oso_turn_off( ) mock_osoenergy_client().hotwater.turn_off.assert_called_once_with(ANY, False) + + +async def test_turn_away_mode_on( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test turning the heater away mode on.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + {ATTR_ENTITY_ID: "water_heater.test_device", ATTR_AWAY_MODE: "on"}, + blocking=True, + ) + + mock_osoenergy_client().hotwater.enable_holiday_mode.assert_called_once_with(ANY) + + +async def test_turn_away_mode_off( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test turning the heater away mode off.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + {ATTR_ENTITY_ID: "water_heater.test_device", ATTR_AWAY_MODE: "off"}, + blocking=True, + ) + + mock_osoenergy_client().hotwater.disable_holiday_mode.assert_called_once_with(ANY) + + +async def test_oso_set_away_mode_on( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test enabling away mode.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_AWAY_MODE_ON, + { + ATTR_ENTITY_ID: "water_heater.test_device", + ATTR_DURATION_DAYS: 10, + }, + blocking=True, + ) + + mock_osoenergy_client().hotwater.enable_holiday_mode.assert_called_once_with( + ANY, 10 + ) diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index 2709f532ef6..f861ccaa9ed 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '01JG00V55WEVTJ0CJHM0GAD7PC', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 3d7bcc3577f..f53c6a917cb 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -7,13 +7,13 @@ from python_overseerr import OverseerrConnectionError from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index fc96cab4fad..3fca1d851ce 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'id': , 'identifiers': set({ }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Palazzetti', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.0.0', 'via_device_id': None, }) diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 8550f1a3de0..65e1025da70 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -102,7 +102,7 @@ async def test_dhcp_flow( result = await hass.config_entries.flow.async_init( DOMAIN, data=DhcpServiceInfo( - hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + hostname="connbox1234", ip="192.168.1.1", macaddress="112233445566" ), context={"source": SOURCE_DHCP}, ) @@ -131,7 +131,7 @@ async def test_dhcp_flow_error( result = await hass.config_entries.flow.async_init( DOMAIN, data=DhcpServiceInfo( - hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + hostname="connbox1234", ip="192.168.1.1", macaddress="112233445566" ), context={"source": SOURCE_DHCP}, ) diff --git a/tests/components/paperless_ngx/test_init.py b/tests/components/paperless_ngx/test_init.py index fd459213ea0..924e3966c79 100644 --- a/tests/components/paperless_ngx/test_init.py +++ b/tests/components/paperless_ngx/test_init.py @@ -63,7 +63,7 @@ async def test_load_config_status_forbidden( "user_inactive_or_deleted", ), (PaperlessForbiddenError(), ConfigEntryState.SETUP_ERROR, "forbidden"), - (InitializationError(), ConfigEntryState.SETUP_ERROR, "cannot_connect"), + (InitializationError(), ConfigEntryState.SETUP_RETRY, "cannot_connect"), ], ) async def test_setup_config_error_handling( diff --git a/tests/components/paperless_ngx/test_sensor.py b/tests/components/paperless_ngx/test_sensor.py index d2233a64ee2..5b5827bca37 100644 --- a/tests/components/paperless_ngx/test_sensor.py +++ b/tests/components/paperless_ngx/test_sensor.py @@ -29,6 +29,7 @@ from tests.common import ( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_platform( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index 8a7cefc523d..21edc32c629 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -25,7 +25,6 @@ '23-45-A4O-MOF', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Peblar', @@ -35,7 +34,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '23-45-A4O-MOF', - 'suggested_area': None, 'sw_version': '1.6.1+1+WL-1', 'via_device_id': None, }) diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 1d6c398c444..c001da86adb 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -244,6 +244,81 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER +async def test_setup_router_ble_trackers( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test router and BLE trackers.""" + # BLE trackers are considered stationary trackers; however unlike a router based tracker + # whose states are home and not_home, a BLE tracker may have the value of any zone that the + # beacon is configured for. + hass.set_state(CoreState.not_running) + user_id = hass_admin_user.id + config = { + DOMAIN: { + "id": "1234", + "name": "tracked person", + "user_id": user_id, + "device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2], + } + } + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get("person.tracked_person") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.states.async_set( + DEVICE_TRACKER, "not_home", {ATTR_SOURCE_TYPE: SourceType.ROUTER} + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "not_home" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_GPS_ACCURACY) is None + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + # Set the BLE tracker to the "office" zone. + hass.states.async_set( + DEVICE_TRACKER_2, + "office", + { + ATTR_LATITUDE: 12.123456, + ATTR_LONGITUDE: 13.123456, + ATTR_GPS_ACCURACY: 12, + ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + }, + ) + await hass.async_block_till_done() + + # The person should be in the office. + state = hass.states.get("person.tracked_person") + assert state.state == "office" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) == 12.123456 + assert state.attributes.get(ATTR_LONGITUDE) == 13.123456 + assert state.attributes.get(ATTR_GPS_ACCURACY) == 12 + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index 60e8b238917..4703f3cb430 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -5,6 +5,7 @@ MOCK_NAME = "Philips TV" MOCK_USERNAME = "mock_user" MOCK_PASSWORD = "mock_password" +MOCK_HOSTNAME = "mock_hostname" MOCK_SYSTEM = { "menulanguage": "English", diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py index 4a79fce85a2..911753a8852 100644 --- a/tests/components/philips_js/conftest.py +++ b/tests/components/philips_js/conftest.py @@ -38,6 +38,7 @@ def mock_tv(): tv.application = None tv.applications = {} tv.system = MOCK_SYSTEM + tv.name = MOCK_NAME tv.api_version = 1 tv.api_version_detected = None tv.on = True diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 4b8048a8ebe..77227fd0f63 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Philips TV config flow.""" +from ipaddress import ip_address from unittest.mock import ANY from haphilipsjs import PairingFailure @@ -9,10 +10,13 @@ from homeassistant import config_entries from homeassistant.components.philips_js.const import CONF_ALLOW_NOTIFY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( MOCK_CONFIG, MOCK_CONFIG_PAIRED, + MOCK_HOSTNAME, + MOCK_NAME, MOCK_PASSWORD, MOCK_SYSTEM, MOCK_SYSTEM_UNPAIRED, @@ -33,6 +37,7 @@ async def mock_tv_pairable(mock_tv): mock_tv.api_version = 6 mock_tv.api_version_detected = 6 mock_tv.secured_transport = True + mock_tv.name = MOCK_NAME mock_tv.pairRequest.return_value = {} mock_tv.pairGrant.return_value = MOCK_USERNAME, MOCK_PASSWORD @@ -102,21 +107,6 @@ async def test_form_cannot_connect(hass: HomeAssistant, mock_tv) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected_error(hass: HomeAssistant, mock_tv) -> None: - """Test we handle unexpected exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_tv.getSystem.side_effect = Exception("Unexpected exception") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MOCK_USERINPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - - async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) -> None: """Test we get the form.""" mock_tv = mock_tv_pairable @@ -135,7 +125,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv.setTransport.assert_called_with(True) + mock_tv.setTransport.assert_called_with(True, ANY) mock_tv.pairRequest.assert_called() result = await hass.config_entries.flow.async_configure( @@ -143,7 +133,13 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) ) assert result == { - "context": {"source": "user", "unique_id": "ABCDEFGHIJKLF"}, + "context": { + "source": "user", + "unique_id": "ABCDEFGHIJKLF", + "title_placeholders": { + "name": "Philips TV", + }, + }, "flow_id": ANY, "type": "create_entry", "description": None, @@ -208,7 +204,7 @@ async def test_pair_grant_failed( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv.setTransport.assert_called_with(True) + mock_tv.setTransport.assert_called_with(True, ANY) mock_tv.pairRequest.assert_called() # Test with invalid pin @@ -258,3 +254,68 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options == {CONF_ALLOW_NOTIFY: True} + + +@pytest.mark.parametrize( + ("secured_transport", "discovery_type"), + [(True, "_philipstv_s_rpc._tcp.local."), (False, "_philipstv_rpc._tcp.local.")], +) +async def test_zeroconf_discovery( + hass: HomeAssistant, mock_tv_pairable, secured_transport, discovery_type +) -> None: + """Test we can setup from zeroconf discovery.""" + + mock_tv_pairable.secured_transport = secured_transport + mock_tv_pairable.api_version_detected = 6 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname=MOCK_HOSTNAME, + name=MOCK_NAME, + port=None, + properties={}, + type=discovery_type, + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_tv_pairable.setTransport.assert_called_with(secured_transport, 6) + mock_tv_pairable.pairRequest.assert_called() + + +async def test_zeroconf_probe_failed( + hass: HomeAssistant, + mock_tv_pairable, +) -> None: + """Test we can setup from zeroconf discovery.""" + + mock_tv_pairable.system = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname=MOCK_HOSTNAME, + name=MOCK_NAME, + port=None, + properties={}, + type="_philipstv_s_rpc._tcp.local.", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failure" diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index 993f6a2571c..c2edb51e066 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -1,8 +1,9 @@ """Tests for the pi_hole component.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from hole.exceptions import HoleError +from hole.exceptions import HoleConnectionError, HoleError from homeassistant.components.pi_hole.const import ( DEFAULT_LOCATION, @@ -12,6 +13,7 @@ from homeassistant.components.pi_hole.const import ( ) from homeassistant.const import ( CONF_API_KEY, + CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -32,6 +34,82 @@ ZERO_DATA = { "unique_clients": 0, "unique_domains": 0, } +ZERO_DATA_V6 = { + "queries": { + "total": 0, + "blocked": 0, + "percent_blocked": 0, + "unique_domains": 0, + "forwarded": 0, + "cached": 0, + "frequency": 0, + "types": { + "A": 0, + "AAAA": 0, + "ANY": 0, + "SRV": 0, + "SOA": 0, + "PTR": 0, + "TXT": 0, + "NAPTR": 0, + "MX": 0, + "DS": 0, + "RRSIG": 0, + "DNSKEY": 0, + "NS": 0, + "SVCB": 0, + "HTTPS": 0, + "OTHER": 0, + }, + "status": { + "UNKNOWN": 0, + "GRAVITY": 0, + "FORWARDED": 0, + "CACHE": 0, + "REGEX": 0, + "DENYLIST": 0, + "EXTERNAL_BLOCKED_IP": 0, + "EXTERNAL_BLOCKED_NULL": 0, + "EXTERNAL_BLOCKED_NXRA": 0, + "GRAVITY_CNAME": 0, + "REGEX_CNAME": 0, + "DENYLIST_CNAME": 0, + "RETRIED": 0, + "RETRIED_DNSSEC": 0, + "IN_PROGRESS": 0, + "DBBUSY": 0, + "SPECIAL_DOMAIN": 0, + "CACHE_STALE": 0, + "EXTERNAL_BLOCKED_EDE15": 0, + }, + "replies": { + "UNKNOWN": 0, + "NODATA": 0, + "NXDOMAIN": 0, + "CNAME": 0, + "IP": 0, + "DOMAIN": 0, + "RRNAME": 0, + "SERVFAIL": 0, + "REFUSED": 0, + "NOTIMP": 0, + "OTHER": 0, + "DNSSEC": 0, + "NONE": 0, + "BLOB": 0, + }, + }, + "clients": {"active": 0, "total": 0}, + "gravity": {"domains_being_blocked": 0, "last_update": 0}, + "took": 0, +} + +FTL_ERROR = { + "error": { + "key": "FTLnotrunning", + "message": "FTL not running", + } +} SAMPLE_VERSIONS_WITH_UPDATES = { "core_current": "v5.5", @@ -62,6 +140,7 @@ PORT = 80 LOCATION = "location" NAME = "Pi hole" API_KEY = "apikey" +API_VERSION = 6 SSL = False VERIFY_SSL = True @@ -72,6 +151,7 @@ CONFIG_DATA_DEFAULTS = { CONF_SSL: DEFAULT_SSL, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_API_KEY: API_KEY, + CONF_API_VERSION: API_VERSION, } CONFIG_DATA = { @@ -81,12 +161,14 @@ CONFIG_DATA = { CONF_API_KEY: API_KEY, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, + CONF_API_VERSION: API_VERSION, } CONFIG_FLOW_USER = { CONF_HOST: HOST, CONF_PORT: PORT, CONF_LOCATION: LOCATION, + CONF_API_KEY: API_KEY, CONF_NAME: NAME, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, @@ -116,42 +198,127 @@ SWITCH_ENTITY_ID = "switch.pi_hole" def _create_mocked_hole( - raise_exception=False, has_versions=True, has_update=True, has_data=True -): - mocked_hole = MagicMock() - type(mocked_hole).get_data = AsyncMock( - side_effect=HoleError("") if raise_exception else None - ) - type(mocked_hole).get_versions = AsyncMock( - side_effect=HoleError("") if raise_exception else None - ) - type(mocked_hole).enable = AsyncMock() - type(mocked_hole).disable = AsyncMock() - if has_data: - mocked_hole.data = ZERO_DATA - else: - mocked_hole.data = [] - if has_versions: - if has_update: - mocked_hole.versions = SAMPLE_VERSIONS_WITH_UPDATES + raise_exception: bool = False, + has_versions: bool = True, + has_update: bool = True, + has_data: bool = True, + api_version: int = 5, + incorrect_app_password: bool = False, + wrong_host: bool = False, + ftl_error: bool = False, +) -> MagicMock: + """Return a mocked Hole API object with side effects based on constructor args.""" + + instances = [] + + def make_mock(**kwargs: Any) -> MagicMock: + mocked_hole = MagicMock() + # Set constructor kwargs as attributes + for key, value in kwargs.items(): + setattr(mocked_hole, key, value) + + async def authenticate_side_effect(*_args, **_kwargs): + if wrong_host: + raise HoleConnectionError("Cannot authenticate with Pi-hole: err") + password = getattr(mocked_hole, "password", None) + + if ( + raise_exception + or incorrect_app_password + or api_version == 5 + or (api_version == 6 and password not in ["newkey", "apikey"]) + ): + if api_version == 6 and ( + incorrect_app_password or password not in ["newkey", "apikey"] + ): + raise HoleError("Authentication failed: Invalid password") + raise HoleConnectionError + + async def get_data_side_effect(*_args, **_kwargs): + """Return data based on the mocked Hole instance state.""" + if wrong_host: + raise HoleConnectionError("Cannot fetch data from Pi-hole: err") + password = getattr(mocked_hole, "password", None) + api_token = getattr(mocked_hole, "api_token", None) + if ( + raise_exception + or incorrect_app_password + or (api_version == 5 and (not api_token or api_token == "wrong_token")) + or (api_version == 6 and password not in ["newkey", "apikey"]) + ): + mocked_hole.data = [] if api_version == 5 else {} + elif password in ["newkey", "apikey"] or api_token in ["newkey", "apikey"]: + mocked_hole.data = ZERO_DATA_V6 if api_version == 6 else ZERO_DATA + + async def ftl_side_effect(): + mocked_hole.data = FTL_ERROR + + mocked_hole.authenticate = AsyncMock(side_effect=authenticate_side_effect) + mocked_hole.get_data = AsyncMock(side_effect=get_data_side_effect) + + if ftl_error: + # two unauthenticated instances are created in `determine_api_version` before aync_try_connect is called + if len(instances) > 1: + mocked_hole.get_data = AsyncMock(side_effect=ftl_side_effect) + mocked_hole.get_versions = AsyncMock(return_value=None) + mocked_hole.enable = AsyncMock() + mocked_hole.disable = AsyncMock() + + # Set versions and version properties + if has_versions: + versions = ( + SAMPLE_VERSIONS_WITH_UPDATES + if has_update + else SAMPLE_VERSIONS_NO_UPDATES + ) + mocked_hole.versions = versions + mocked_hole.ftl_current = versions["FTL_current"] + mocked_hole.ftl_latest = versions["FTL_latest"] + mocked_hole.ftl_update = versions["FTL_update"] + mocked_hole.core_current = versions["core_current"] + mocked_hole.core_latest = versions["core_latest"] + mocked_hole.core_update = versions["core_update"] + mocked_hole.web_current = versions["web_current"] + mocked_hole.web_latest = versions["web_latest"] + mocked_hole.web_update = versions["web_update"] else: - mocked_hole.versions = SAMPLE_VERSIONS_NO_UPDATES - else: - mocked_hole.versions = None - return mocked_hole + mocked_hole.versions = None + + # Set initial data + if has_data: + mocked_hole.data = ZERO_DATA_V6 if api_version == 6 else ZERO_DATA + else: + mocked_hole.data = [] if api_version == 5 else {} + instances.append(mocked_hole) + return mocked_hole + + # Return a factory function for patching + make_mock.instances = instances + return make_mock def _patch_init_hole(mocked_hole): - return patch("homeassistant.components.pi_hole.Hole", return_value=mocked_hole) + """Patch the Hole class in the main integration.""" + + def side_effect(*args, **kwargs): + return mocked_hole(**kwargs) + + return patch("homeassistant.components.pi_hole.Hole", side_effect=side_effect) def _patch_config_flow_hole(mocked_hole): + """Patch the Hole class in the config flow.""" + + def side_effect(*args, **kwargs): + return mocked_hole(**kwargs) + return patch( - "homeassistant.components.pi_hole.config_flow.Hole", return_value=mocked_hole + "homeassistant.components.pi_hole.config_flow.Hole", side_effect=side_effect ) def _patch_setup_hole(): + """Patch async_setup_entry for the integration.""" return patch( "homeassistant.components.pi_hole.async_setup_entry", return_value=True ) diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 2d6f6687d04..58f4302f226 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -16,6 +16,7 @@ 'entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', + 'api_version': 5, 'host': '1.2.3.4:80', 'location': 'admin', 'name': 'Pi-Hole', diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index d13712d6f76..e79f65b406e 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -10,9 +10,8 @@ from homeassistant.data_entry_flow import FlowResultType from . import ( CONFIG_DATA_DEFAULTS, CONFIG_ENTRY_WITH_API_KEY, - CONFIG_ENTRY_WITHOUT_API_KEY, - CONFIG_FLOW_API_KEY, CONFIG_FLOW_USER, + FTL_ERROR, NAME, ZERO_DATA, _create_mocked_hole, @@ -24,10 +23,14 @@ from . import ( from tests.common import MockConfigEntry -async def test_flow_user_with_api_key(hass: HomeAssistant) -> None: +async def test_flow_user_with_api_key_v6(hass: HomeAssistant) -> None: """Test user initialized flow with api key needed.""" - mocked_hole = _create_mocked_hole(has_data=False) - with _patch_config_flow_hole(mocked_hole), _patch_setup_hole() as mock_setup: + mocked_hole = _create_mocked_hole(has_data=False, api_version=6) + with ( + _patch_init_hole(mocked_hole), + _patch_config_flow_hole(mocked_hole), + _patch_setup_hole() as mock_setup, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -38,27 +41,19 @@ async def test_flow_user_with_api_key(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONFIG_FLOW_USER, + user_input={**CONFIG_FLOW_USER, CONF_API_KEY: "invalid_password"}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "api_key" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_API_KEY: "some_key"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "api_key" + # we have had no response from the server yet, so we expect an error assert result["errors"] == {CONF_API_KEY: "invalid_auth"} - mocked_hole.data = ZERO_DATA + # now we have a valid passiword result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONFIG_FLOW_API_KEY, + user_input=CONFIG_FLOW_USER, ) + + # form should be complete with a valid config entry assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME assert result["data"] == CONFIG_ENTRY_WITH_API_KEY mock_setup.assert_called_once() @@ -72,10 +67,15 @@ async def test_flow_user_with_api_key(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_flow_user_without_api_key(hass: HomeAssistant) -> None: - """Test user initialized flow without api key needed.""" - mocked_hole = _create_mocked_hole() - with _patch_config_flow_hole(mocked_hole), _patch_setup_hole() as mock_setup: +async def test_flow_user_with_api_key_v5(hass: HomeAssistant) -> None: + """Test user initialized flow with api key needed.""" + mocked_hole = _create_mocked_hole(api_version=5) + with ( + _patch_init_hole(mocked_hole), + _patch_config_flow_hole(mocked_hole), + _patch_setup_hole() as mock_setup, + ): + # start the flow as a user initiated flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -84,32 +84,72 @@ async def test_flow_user_without_api_key(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} + # configure the flow with an invalid api key + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**CONFIG_FLOW_USER, CONF_API_KEY: "wrong_token"}, + ) + + # confirm an invalid authentication error + assert result["errors"] == {CONF_API_KEY: "invalid_auth"} + + # configure the flow with a valid api key result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG_FLOW_USER, ) + + # in API V5 we get data to confirm authentication + assert mocked_hole.instances[-1].data == ZERO_DATA + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == NAME - assert result["data"] == CONFIG_ENTRY_WITHOUT_API_KEY + assert result["data"] == {**CONFIG_ENTRY_WITH_API_KEY} mock_setup.assert_called_once() + # duplicated server + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_FLOW_USER, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + async def test_flow_user_invalid(hass: HomeAssistant) -> None: - """Test user initialized flow with invalid server.""" + """Test user initialized flow with completely invalid server.""" mocked_hole = _create_mocked_hole(raise_exception=True) - with _patch_config_flow_hole(mocked_hole): + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"api_key": "invalid_auth"} + + +async def test_flow_user_invalid_v6(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid server - typically a V6 API and a incorrect app password.""" + mocked_hole = _create_mocked_hole( + has_data=True, api_version=6, incorrect_app_password=True + ) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"api_key": "invalid_auth"} async def test_flow_reauth(hass: HomeAssistant) -> None: """Test reauth flow.""" - mocked_hole = _create_mocked_hole(has_data=False) - entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) + mocked_hole = _create_mocked_hole(has_data=False, api_version=5) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, + data={**CONFIG_DATA_DEFAULTS, CONF_API_KEY: "oldkey"}, + ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole), _patch_config_flow_hole(mocked_hole): assert not await hass.config_entries.async_setup(entry.entry_id) @@ -120,9 +160,7 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" assert flows[0]["context"]["entry_id"] == entry.entry_id - - mocked_hole.data = ZERO_DATA - + mocked_hole.instances[-1].api_token = "newkey" result = await hass.config_entries.flow.async_configure( flows[0]["flow_id"], user_input={CONF_API_KEY: "newkey"}, @@ -131,3 +169,28 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert entry.data[CONF_API_KEY] == "newkey" + + +async def test_flow_user_invalid_host(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid server host address.""" + mocked_hole = _create_mocked_hole(api_version=6, wrong_host=True) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_error_response(hass: HomeAssistant) -> None: + """Test user initialized flow but dataotherbase errors occur.""" + mocked_hole = _create_mocked_hole(api_version=5, ftl_error=True, has_data=False) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + ) + assert mocked_hole.instances[-1].data == FTL_ERROR + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/pi_hole/test_diagnostics.py b/tests/components/pi_hole/test_diagnostics.py index 8d5a83e4622..678efdf078e 100644 --- a/tests/components/pi_hole/test_diagnostics.py +++ b/tests/components/pi_hole/test_diagnostics.py @@ -19,9 +19,10 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Tests diagnostics.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=5) + config_entry = {**CONFIG_DATA_DEFAULTS, "api_version": 5} entry = MockConfigEntry( - domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS, entry_id="pi_hole_mock_entry" + domain=pi_hole.DOMAIN, data=config_entry, entry_id="pi_hole_mock_entry" ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index 72b48e3d572..94170e967d4 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -16,6 +16,7 @@ from homeassistant.components.pi_hole.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -27,7 +28,7 @@ from . import ( API_KEY, CONFIG_DATA, CONFIG_DATA_DEFAULTS, - CONFIG_ENTRY_WITHOUT_API_KEY, + DEFAULT_VERIFY_SSL, SWITCH_ENTITY_ID, _create_mocked_hole, _patch_init_hole, @@ -38,32 +39,62 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( ("config_entry_data", "expected_api_token"), - [(CONFIG_DATA_DEFAULTS, API_KEY), (CONFIG_ENTRY_WITHOUT_API_KEY, "")], + [(CONFIG_DATA_DEFAULTS, API_KEY)], ) -async def test_setup_api( +async def test_setup_api_v6( hass: HomeAssistant, config_entry_data: dict, expected_api_token: str ) -> None: """Tests the API object is created with the expected parameters.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) + config_entry_data = {**config_entry_data} + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config_entry_data) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole) as patched_init_hole: + assert await hass.config_entries.async_setup(entry.entry_id) + patched_init_hole.assert_called_with( + host=config_entry_data[CONF_HOST], + session=ANY, + password=expected_api_token, + location=config_entry_data[CONF_LOCATION], + protocol="http", + version=6, + verify_tls=DEFAULT_VERIFY_SSL, + ) + + +@pytest.mark.parametrize( + ("config_entry_data", "expected_api_token"), + [({**CONFIG_DATA_DEFAULTS}, API_KEY)], +) +async def test_setup_api_v5( + hass: HomeAssistant, config_entry_data: dict, expected_api_token: str +) -> None: + """Tests the API object is created with the expected parameters.""" + mocked_hole = _create_mocked_hole(api_version=5) + config_entry_data = {**config_entry_data} + config_entry_data[CONF_API_VERSION] = 5 config_entry_data = {**config_entry_data, CONF_STATISTICS_ONLY: True} entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config_entry_data) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole) as patched_init_hole: assert await hass.config_entries.async_setup(entry.entry_id) - patched_init_hole.assert_called_once_with( - config_entry_data[CONF_HOST], - ANY, + patched_init_hole.assert_called_with( + host=config_entry_data[CONF_HOST], + session=ANY, api_token=expected_api_token, location=config_entry_data[CONF_LOCATION], tls=config_entry_data[CONF_SSL], + version=5, + verify_tls=DEFAULT_VERIFY_SSL, ) -async def test_setup_with_defaults(hass: HomeAssistant) -> None: +async def test_setup_with_defaults_v5(hass: HomeAssistant) -> None: """Tests component setup with default config.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=5) entry = MockConfigEntry( - domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + domain=pi_hole.DOMAIN, + data={**CONFIG_DATA_DEFAULTS, CONF_API_VERSION: 5, CONF_STATISTICS_ONLY: True}, ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -110,9 +141,87 @@ async def test_setup_with_defaults(hass: HomeAssistant) -> None: assert state.state == "off" +async def test_setup_with_defaults_v6(hass: HomeAssistant) -> None: + """Tests component setup with default config.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state is not None + assert state.name == "Pi-Hole Ads blocked" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_ads_percentage_blocked") + assert state.name == "Pi-Hole Ads percentage blocked" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_queries_cached") + assert state.name == "Pi-Hole DNS queries cached" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_queries_forwarded") + assert state.name == "Pi-Hole DNS queries forwarded" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_queries") + assert state.name == "Pi-Hole DNS queries" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_unique_clients") + assert state.name == "Pi-Hole DNS unique clients" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_unique_domains") + assert state.name == "Pi-Hole DNS unique domains" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_domains_blocked") + assert state.name == "Pi-Hole Domains blocked" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_seen_clients") + assert state.name == "Pi-Hole Seen clients" + assert state.state == "0" + + state = hass.states.get("binary_sensor.pi_hole_status") + assert state.name == "Pi-Hole Status" + assert state.state == "off" + + +async def test_setup_without_api_version(hass: HomeAssistant) -> None: + """Tests component setup without API version.""" + + mocked_hole = _create_mocked_hole(api_version=6) + config = {**CONFIG_DATA_DEFAULTS} + config.pop(CONF_API_VERSION) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.runtime_data.api_version == 6 + + mocked_hole = _create_mocked_hole(api_version=5) + config = {**CONFIG_DATA_DEFAULTS} + config.pop(CONF_API_VERSION) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.runtime_data.api_version == 5 + + async def test_setup_name_config(hass: HomeAssistant) -> None: """Tests component setup with a custom name.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) entry = MockConfigEntry( domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_NAME: "Custom"} ) @@ -122,16 +231,15 @@ async def test_setup_name_config(hass: HomeAssistant) -> None: await hass.async_block_till_done() - assert ( - hass.states.get("sensor.custom_ads_blocked_today").name - == "Custom Ads blocked today" - ) + assert hass.states.get("sensor.custom_ads_blocked").name == "Custom Ads blocked" async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test Pi-hole switch.""" mocked_hole = _create_mocked_hole() - entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA, CONF_API_VERSION: 5} + ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -145,7 +253,7 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> {"entity_id": SWITCH_ENTITY_ID}, blocking=True, ) - mocked_hole.enable.assert_called_once() + mocked_hole.instances[-1].enable.assert_called_once() await hass.services.async_call( switch.DOMAIN, @@ -153,17 +261,17 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> {"entity_id": SWITCH_ENTITY_ID}, blocking=True, ) - mocked_hole.disable.assert_called_once_with(True) + mocked_hole.instances[-1].disable.assert_called_once_with(True) # Failed calls - type(mocked_hole).enable = AsyncMock(side_effect=HoleError("Error1")) + mocked_hole.instances[-1].enable = AsyncMock(side_effect=HoleError("Error1")) await hass.services.async_call( switch.DOMAIN, switch.SERVICE_TURN_ON, {"entity_id": SWITCH_ENTITY_ID}, blocking=True, ) - type(mocked_hole).disable = AsyncMock(side_effect=HoleError("Error2")) + mocked_hole.instances[-1].disable = AsyncMock(side_effect=HoleError("Error2")) await hass.services.async_call( switch.DOMAIN, switch.SERVICE_TURN_OFF, @@ -171,6 +279,7 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> blocking=True, ) errors = [x for x in caplog.records if x.levelno == logging.ERROR] + assert errors[-2].message == "Unable to enable Pi-hole: Error1" assert errors[-1].message == "Unable to disable Pi-hole: Error2" @@ -178,7 +287,7 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> async def test_disable_service_call(hass: HomeAssistant) -> None: """Test disable service call with no Pi-hole named.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) with _patch_init_hole(mocked_hole): entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA) entry.add_to_hass(hass) @@ -199,7 +308,7 @@ async def test_disable_service_call(hass: HomeAssistant) -> None: blocking=True, ) - mocked_hole.disable.assert_called_with(1) + mocked_hole.instances[-1].disable.assert_called_with(1) async def test_unload(hass: HomeAssistant) -> None: @@ -209,7 +318,7 @@ async def test_unload(hass: HomeAssistant) -> None: data={**CONFIG_DATA_DEFAULTS, CONF_HOST: "pi.hole"}, ) entry.add_to_hass(hass) - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) with _patch_init_hole(mocked_hole): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -222,7 +331,7 @@ async def test_unload(hass: HomeAssistant) -> None: async def test_remove_obsolete(hass: HomeAssistant) -> None: """Test removing obsolete config entry parameters.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) entry = MockConfigEntry( domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} ) diff --git a/tests/components/pi_hole/test_repairs.py b/tests/components/pi_hole/test_repairs.py new file mode 100644 index 00000000000..4982b1544c7 --- /dev/null +++ b/tests/components/pi_hole/test_repairs.py @@ -0,0 +1,136 @@ +"""Test pi_hole component.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from hole.exceptions import HoleConnectionError, HoleError +import pytest + +import homeassistant +from homeassistant.components import pi_hole +from homeassistant.components.pi_hole.const import VERSION_6_RESPONSE_TO_5_ERROR +from homeassistant.const import CONF_API_VERSION, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util + +from . import CONFIG_DATA_DEFAULTS, ZERO_DATA, _create_mocked_hole, _patch_init_hole + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_change_api_5_to_6( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Tests a user with an API version 5 config entry that is updated to API version 6.""" + mocked_hole = _create_mocked_hole(api_version=5) + + # setu up a valid API version 5 config entry + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, + data={**CONFIG_DATA_DEFAULTS, CONF_API_VERSION: 5}, + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert mocked_hole.instances[-1].data == ZERO_DATA + # Change the mock's state after setup + mocked_hole.instances[-1].hole_version = 6 + mocked_hole.instances[-1].api_token = "wrong_token" + + # Patch the method on the coordinator's api reference directly + pihole_data = entry.runtime_data + assert pihole_data.api == mocked_hole.instances[-1] + pihole_data.api.get_data = AsyncMock( + side_effect=lambda: setattr( + pihole_data.api, + "data", + {"error": VERSION_6_RESPONSE_TO_5_ERROR, "took": 0.0001430511474609375}, + ) + ) + + # Now trigger the update + with pytest.raises(homeassistant.exceptions.ConfigEntryAuthFailed): + await pihole_data.coordinator.update_method() + assert pihole_data.api.data == { + "error": VERSION_6_RESPONSE_TO_5_ERROR, + "took": 0.0001430511474609375, + } + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + # ensure a re-auth flow is created + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + assert flows[0]["context"]["entry_id"] == entry.entry_id + + +async def test_app_password_changing( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Tests a user with an API version 5 config entry that is updated to API version 6.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS}) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state is not None + assert state.name == "Pi-Hole Ads blocked" + assert state.state == "0" + + # Test app password changing + async def fail_auth(): + """Set mocked data to bad_data.""" + raise HoleError("Authentication failed: Invalid password") + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=fail_auth) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + assert flows[0]["context"]["entry_id"] == entry.entry_id + + # Test app password changing + async def fail_fetch(): + """Set mocked data to bad_data.""" + raise HoleConnectionError("Cannot fetch data from Pi-hole: 200") + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=fail_fetch) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + +async def test_app_failed_fetch( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Tests a user with an API version 5 config entry that is updated to API version 6.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS}) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state.state == "0" + + # Test fetch failing changing + async def fail_fetch(): + """Set mocked data to bad_data.""" + raise HoleConnectionError("Cannot fetch data from Pi-hole: 200") + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=fail_fetch) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/pi_hole/test_sensor.py b/tests/components/pi_hole/test_sensor.py new file mode 100644 index 00000000000..7d3efd938fe --- /dev/null +++ b/tests/components/pi_hole/test_sensor.py @@ -0,0 +1,79 @@ +"""Test pi_hole component.""" + +import copy +from datetime import timedelta +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components import pi_hole +from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import CONFIG_DATA_DEFAULTS, ZERO_DATA_V6, _create_mocked_hole, _patch_init_hole + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_bad_data_type( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling of bad data. Mostly for code coverage, rather than simulating known error states.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + bad_data = copy.deepcopy(ZERO_DATA_V6) + bad_data["queries"]["total"] = "error string" + assert bad_data != ZERO_DATA_V6 + + async def set_bad_data(): + """Set mocked data to bad_data.""" + mocked_hole.instances[-1].data = bad_data + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=set_bad_data) + + # Wait a minute + future = dt_util.utcnow() + timedelta(minutes=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert "TypeError" in caplog.text + + +async def test_bad_data_key( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling of bad data. Mostly for code coverage, rather than simulating known error states.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + bad_data = copy.deepcopy(ZERO_DATA_V6) + # remove a whole part of the dict tree now + bad_data["queries"] = "error string" + assert bad_data != ZERO_DATA_V6 + + async def set_bad_data(): + """Set mocked data to bad_data.""" + mocked_hole.instances[-1].data = bad_data + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=set_bad_data) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + assert mocked_hole.instances[-1].data != ZERO_DATA_V6 + + assert "KeyError" in caplog.text diff --git a/tests/components/pi_hole/test_update.py b/tests/components/pi_hole/test_update.py index 705e9f9c08d..5e81d91b5bd 100644 --- a/tests/components/pi_hole/test_update.py +++ b/tests/components/pi_hole/test_update.py @@ -11,7 +11,7 @@ from tests.common import MockConfigEntry async def test_update(hass: HomeAssistant) -> None: """Tests update entity.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -52,7 +52,7 @@ async def test_update(hass: HomeAssistant) -> None: async def test_update_no_versions(hass: HomeAssistant) -> None: """Tests update entity when no version data available.""" - mocked_hole = _create_mocked_hole(has_versions=False) + mocked_hole = _create_mocked_hole(has_versions=False, api_version=6) entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -84,7 +84,9 @@ async def test_update_no_versions(hass: HomeAssistant) -> None: async def test_update_no_updates(hass: HomeAssistant) -> None: """Tests update entity when no latest data available.""" - mocked_hole = _create_mocked_hole(has_versions=True, has_update=False) + mocked_hole = _create_mocked_hole( + has_versions=True, has_update=False, api_version=6 + ) entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index f09bfe61065..8cb8642f13a 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -1,4 +1,59 @@ # serializer version: 1 +# name: test_setup_and_update[jitter] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.10_10_10_10_jitter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Jitter', + 'platform': 'ping', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'jitter', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_and_update[jitter].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '10.10.10.10 Jitter', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.10_10_10_10_jitter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.5', + }) +# --- # name: test_setup_and_update[round_trip_time_average] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ping/test_sensor.py b/tests/components/ping/test_sensor.py index bdc8b7d28e4..95a31aa5c08 100644 --- a/tests/components/ping/test_sensor.py +++ b/tests/components/ping/test_sensor.py @@ -16,6 +16,7 @@ from homeassistant.helpers import entity_registry as er "round_trip_time_maximum", "round_trip_time_mean_deviation", # should be None in the snapshot "round_trip_time_minimum", + "jitter", ], ) async def test_setup_and_update( diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index f03b3e6f1cf..bfbdc9a72bd 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -1,11 +1,21 @@ """Common fixtures for the Playstation Network tests.""" from collections.abc import Generator +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch +from psnawp_api.models import User +from psnawp_api.models.group.group import Group +from psnawp_api.models.trophies import ( + PlatformType, + TrophySet, + TrophySummary, + TrophyTitle, +) import pytest from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN +from homeassistant.config_entries import ConfigSubentryData from tests.common import MockConfigEntry @@ -24,6 +34,15 @@ def mock_config_entry() -> MockConfigEntry: CONF_NPSSO: NPSSO_TOKEN, }, unique_id=PSN_ID, + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_id="ABCDEF", + subentry_type="friend", + title="PublicUniversalFriend", + unique_id="fren-psn-id", + ) + ], ) @@ -63,6 +82,7 @@ def mock_user() -> Generator[MagicMock]: "conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png", } ], + "lastAvailableDate": "2025-06-30T01:42:15.391Z", } } @@ -81,14 +101,92 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: client.user.return_value = mock_user client.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PSVITA"}, { "deviceId": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", "deviceType": "PS5", "activationType": "PRIMARY", "activationDate": "2021-01-14T18:00:00.000Z", "accountDeviceVector": "abcdefghijklmnopqrstuv", - } + }, ] + client.me.return_value.trophy_summary.return_value = TrophySummary( + PSN_ID, 1079, 19, 10, TrophySet(14450, 8722, 11754, 1398) + ) + client.user.return_value.profile.return_value = { + "onlineId": "testuser", + "personalDetail": { + "firstName": "Rick", + "lastName": "Astley", + "profilePictures": [ + { + "size": "xl", + "url": "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png", + } + ], + }, + "aboutMe": "Never Gonna Give You Up", + "avatars": [ + { + "size": "xl", + "url": "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png", + } + ], + "languages": ["de-DE"], + "isPlus": True, + "isOfficiallyVerified": False, + "isMe": True, + } + client.user.return_value.trophy_titles.return_value = [ + TrophyTitle( + np_service_name="trophy", + np_communication_id="NPWR03134_00", + trophy_set_version="01.03", + title_name="Assassin's Creed® III Liberation", + title_detail="Assassin's Creed® III Liberation", + title_icon_url="https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG", + title_platform=frozenset({PlatformType.PS_VITA}), + has_trophy_groups=False, + progress=28, + hidden_flag=False, + earned_trophies=TrophySet(bronze=4, silver=8, gold=0, platinum=0), + defined_trophies=TrophySet(bronze=22, silver=21, gold=1, platinum=1), + last_updated_datetime=datetime(2016, 10, 6, 18, 5, 8, tzinfo=UTC), + np_title_id=None, + ) + ] + client.me.return_value.get_profile_legacy.return_value = { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + } + client.me.return_value.get_shareable_profile_link.return_value = { + "shareImageUrl": "https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493" + } + group = MagicMock(spec=Group, group_id="test-groupid") + + group.get_group_information.return_value = { + "groupName": {"value": ""}, + "members": [ + {"onlineId": "PublicUniversalFriend", "accountId": "fren-psn-id"}, + {"onlineId": "testuser", "accountId": PSN_ID}, + ], + } + client.me.return_value.get_groups.return_value = [group] + fren = MagicMock( + spec=User, account_id="fren-psn-id", online_id="PublicUniversalFriend" + ) + + client.user.return_value.friends_list.return_value = [fren] + yield client diff --git a/tests/components/playstation_network/snapshots/test_binary_sensor.ambr b/tests/components/playstation_network/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..f380f91e9b9 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_binary_sensor.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_sensors[binary_sensor.testuser_subscribed_to_playstation_plus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.testuser_subscribed_to_playstation_plus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Subscribed to PlayStation Plus', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_ps_plus_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.testuser_subscribed_to_playstation_plus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Subscribed to PlayStation Plus', + }), + 'context': , + 'entity_id': 'binary_sensor.testuser_subscribed_to_playstation_plus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ca5e9f98628 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'account_id': '**REDACTED**', + 'active_sessions': dict({ + 'PS5': dict({ + 'format': 'PS5', + 'media_image_url': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'platform': 'PS5', + 'status': 'online', + 'title_id': 'PPSA07784_00', + 'title_name': 'STAR WARS Jedi: Survivor™', + }), + 'PSVITA': dict({ + 'format': 'PSVITA', + 'media_image_url': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'platform': 'PSVITA', + 'status': 'online', + 'title_id': 'PCSB00074_00', + 'title_name': "Assassin's Creed® III Liberation", + }), + }), + 'presence': dict({ + 'basicPresence': dict({ + 'availability': 'availableToPlay', + 'gameTitleInfoList': list([ + dict({ + 'conceptIconUrl': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'format': 'PS5', + 'launchPlatform': 'PS5', + 'npTitleId': 'PPSA07784_00', + 'titleName': 'STAR WARS Jedi: Survivor™', + }), + ]), + 'lastAvailableDate': '2025-06-30T01:42:15.391Z', + 'primaryPlatformInfo': dict({ + 'onlineStatus': 'online', + 'platform': 'PS5', + }), + }), + }), + 'profile': dict({ + 'aboutMe': 'Never Gonna Give You Up', + 'avatars': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + 'isMe': True, + 'isOfficiallyVerified': False, + 'isPlus': True, + 'languages': list([ + 'de-DE', + ]), + 'onlineId': '**REDACTED**', + 'personalDetail': dict({ + 'firstName': '**REDACTED**', + 'lastName': '**REDACTED**', + 'profilePictures': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + }), + }), + 'registered_platforms': list([ + 'PS5', + 'PSVITA', + ]), + 'shareable_profile_link': '**REDACTED**', + 'trophy_summary': dict({ + 'account_id': '**REDACTED**', + 'earned_trophies': dict({ + 'bronze': 14450, + 'gold': 11754, + 'platinum': 1398, + 'silver': 8722, + }), + 'progress': 19, + 'tier': 10, + 'trophy_level': 1079, + }), + 'username': '**REDACTED**', + }), + 'groups': dict({ + 'test-groupid': dict({ + 'groupName': dict({ + 'value': '', + }), + 'members': '**REDACTED**', + }), + }), + }) +# --- diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index a42522592e4..891509b351c 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -1,4 +1,164 @@ # serializer version: 1 +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'PlayStation Vita', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_vita?token=123456789&cache=c7c916a6e18aec3d', + 'friendly_name': 'PlayStation Vita', + 'media_content_id': 'PCSB00074_00', + 'media_content_type': , + 'media_title': "Assassin's Creed® III Liberation", + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation Vita', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform[PS4_idle][media_player.playstation_4-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/snapshots/test_notify.ambr b/tests/components/playstation_network/snapshots/test_notify.ambr new file mode 100644 index 00000000000..d8c32918433 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_notify.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_notify_platform[notify.testuser_direct_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.testuser_direct_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direct message', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_direct_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.testuser_direct_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Direct message', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.testuser_direct_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group: PublicUniversalFriend', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_test-groupid', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Group: PublicUniversalFriend', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..046989cebe6 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -0,0 +1,710 @@ +# serializer version: 1 +# name: test_sensors[sensor.testuser_bronze_trophies-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_bronze_trophies', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bronze trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_bronze', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_bronze_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Bronze trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_bronze_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14450', + }) +# --- +# name: test_sensors[sensor.testuser_gold_trophies-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_gold_trophies', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gold trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_gold', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_gold_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Gold trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_gold_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11754', + }) +# --- +# name: test_sensors[sensor.testuser_last_online-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_last_online', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testuser Last online', + }), + 'context': , + 'entity_id': 'sensor.testuser_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-06-30T01:42:15+00:00', + }) +# --- +# name: test_sensors[sensor.testuser_last_online_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_last_online_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_last_online_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testuser Last online', + }), + 'context': , + 'entity_id': 'sensor.testuser_last_online_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-06-30T01:42:15+00:00', + }) +# --- +# name: test_sensors[sensor.testuser_next_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_next_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next level', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_trophy_level_progress', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.testuser_next_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Next level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.testuser_next_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19', + }) +# --- +# name: test_sensors[sensor.testuser_now_playing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_now_playing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Now playing', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Now playing', + }), + 'context': , + 'entity_id': 'sensor.testuser_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'STAR WARS Jedi: Survivor™', + }) +# --- +# name: test_sensors[sensor.testuser_now_playing_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_now_playing_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Now playing', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_now_playing_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Now playing', + }), + 'context': , + 'entity_id': 'sensor.testuser_now_playing_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'STAR WARS Jedi: Survivor™', + }) +# --- +# name: test_sensors[sensor.testuser_online_id-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_online_id', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Online ID', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_online_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_id-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png', + 'friendly_name': 'testuser Online ID', + }), + 'context': , + 'entity_id': 'sensor.testuser_online_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'testuser', + }) +# --- +# name: test_sensors[sensor.testuser_online_id_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_online_id_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Online ID', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_online_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_id_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png', + 'friendly_name': 'testuser Online ID', + }), + 'context': , + 'entity_id': 'sensor.testuser_online_id_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'testuser', + }) +# --- +# name: test_sensors[sensor.testuser_online_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_online_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Online status', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_online_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'testuser Online status', + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'context': , + 'entity_id': 'sensor.testuser_online_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'availabletoplay', + }) +# --- +# name: test_sensors[sensor.testuser_online_status_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_online_status_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Online status', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_online_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_status_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'testuser Online status', + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'context': , + 'entity_id': 'sensor.testuser_online_status_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'availabletoplay', + }) +# --- +# name: test_sensors[sensor.testuser_platinum_trophies-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_platinum_trophies', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Platinum trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_platinum', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_platinum_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Platinum trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_platinum_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1398', + }) +# --- +# name: test_sensors[sensor.testuser_silver_trophies-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_silver_trophies', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Silver trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_silver', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_silver_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Silver trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_silver_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8722', + }) +# --- +# name: test_sensors[sensor.testuser_trophy_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_trophy_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trophy level', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_trophy_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_trophy_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Trophy level', + }), + 'context': , + 'entity_id': 'sensor.testuser_trophy_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1079', + }) +# --- diff --git a/tests/components/playstation_network/test_binary_sensor.py b/tests/components/playstation_network/test_binary_sensor.py new file mode 100644 index 00000000000..de7ef630b76 --- /dev/null +++ b/tests/components/playstation_network/test_binary_sensor.py @@ -0,0 +1,42 @@ +"""Test the Playstation Network binary sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def binary_sensor_only() -> Generator[None]: + """Enable only the binary sensor platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the PlayStation Network binary sensor platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 981e459d283..0cd94fe153a 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -10,8 +10,17 @@ from homeassistant.components.playstation_network.config_flow import ( PSNAWPInvalidTokenError, PSNAWPNotFoundError, ) -from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.components.playstation_network.const import ( + CONF_ACCOUNT_ID, + CONF_NPSSO, + DOMAIN, +) +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntryState, + ConfigSubentry, + ConfigSubentryData, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -67,6 +76,45 @@ async def test_form_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_form_already_configured_as_subentry(hass: HomeAssistant) -> None: + """Test we abort form login when entry is already configured as subentry of another entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="PublicUniversalFriend", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id="fren-psn-id", + subentries_data=[ + ConfigSubentryData( + data={CONF_ACCOUNT_ID: PSN_ID}, + subentry_id="ABCDEF", + subentry_type="friend", + title="test-user", + unique_id=PSN_ID, + ) + ], + ) + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_subentry" + + @pytest.mark.parametrize( ("raise_error", "text_error"), [ @@ -296,3 +344,176 @@ async def test_flow_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_add_friend_flow(hass: HomeAssistant) -> None: + """Test add friend subentry flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id=PSN_ID, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT_ID: "fren-psn-id"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type="friend", + title="PublicUniversalFriend", + unique_id="fren-psn-id", + ) + } + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_add_friend_flow_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test we abort add friend subentry flow when already configured.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT_ID: "fren-psn-id"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_add_friend_flow_already_configured_as_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test we abort add friend subentry flow when already configured as config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id=PSN_ID, + ) + fren_config_entry = MockConfigEntry( + domain=DOMAIN, + title="PublicUniversalFriend", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id="fren-psn-id", + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + fren_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(fren_config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT_ID: "fren-psn-id"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_entry" + + +async def test_add_friend_flow_no_friends( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test we abort add friend subentry flow when the user has no friends.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.friends_list.return_value = [] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_friends" diff --git a/tests/components/playstation_network/test_diagnostics.py b/tests/components/playstation_network/test_diagnostics.py new file mode 100644 index 00000000000..b803a213207 --- /dev/null +++ b/tests/components/playstation_network/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for PlayStation Network diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/playstation_network/test_image.py b/tests/components/playstation_network/test_image.py new file mode 100644 index 00000000000..0dc52646d9e --- /dev/null +++ b/tests/components/playstation_network/test_image.py @@ -0,0 +1,96 @@ +"""Test the PlayStation Network image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +import respx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@respx.mock +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + mock_psnawpapi: MagicMock, +) -> None: + """Test image platform.""" + freezer.move_to("2025-06-16T00:00:00-00:00") + + respx.get( + "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png" + ).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test") + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + ava = "https://static-resource.np.community.playstation.net/avatar_m/WWS_E/E0011_m.png" + profile = mock_psnawpapi.user.return_value.profile.return_value + profile["avatars"] = [{"size": "xl", "url": ava}] + mock_psnawpapi.user.return_value.profile.return_value = profile + respx.get(ava).respond( + status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:30+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test2" + assert resp.content_type == "image/png" + assert resp.content_length == 5 diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py new file mode 100644 index 00000000000..6db4cb6ab6a --- /dev/null +++ b/tests/components/playstation_network/test_init.py @@ -0,0 +1,346 @@ +"""Tests for PlayStation Network.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from psnawp_api.core import ( + PSNAWPAuthenticationError, + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +import pytest + +from homeassistant.components.playstation_network.const import DOMAIN +from homeassistant.components.playstation_network.coordinator import ( + PlaystationNetworkRuntimeData, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test config entry not ready.""" + + mock_psnawpapi.user.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test config entry auth failed setup error.""" + + mock_psnawpapi.user.side_effect = PSNAWPAuthenticationError + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test coordinator data update failed.""" + + mock_psnawpapi.user.return_value.get_presence.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_update_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test coordinator update auth failed setup error.""" + + mock_psnawpapi.user.return_value.get_presence.side_effect = ( + PSNAWPAuthenticationError + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +async def test_trophy_title_coordinator( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator updates when PS Vita is registered.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + +async def test_trophy_title_coordinator_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator starts reauth on authentication error.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = ( + PSNAWPAuthenticationError + ) + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_trophy_title_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator update failed.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = exception + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + runtime_data: PlaystationNetworkRuntimeData = config_entry.runtime_data + assert runtime_data.trophy_titles.last_update_success is False + + +async def test_trophy_title_coordinator_doesnt_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator does not update if no PS Vita is registered.""" + + mock_psnawpapi.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PS5"}, + {"deviceType": "PS3"}, + ] + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + +async def test_trophy_title_coordinator_play_new_game( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we play a new game and get a title image on next trophy titles update.""" + + _tmp = mock_psnawpapi.user.return_value.trophy_titles.return_value + mock_psnawpapi.user.return_value.trophy_titles.return_value = [] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("media_player.playstation_vita")) + assert state.attributes.get("entity_picture") is None + + mock_psnawpapi.user.return_value.trophy_titles.return_value = _tmp + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + assert (state := hass.states.get("media_player.playstation_vita")) + assert ( + state.attributes["entity_picture"] + == "https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG" + ) + + +@pytest.mark.parametrize( + "exception", + [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError, PSNAWPForbiddenError], +) +async def test_friends_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test friends coordinator setup fails in _update_data.""" + + mock_psnawpapi.user.return_value.get_presence.side_effect = [ + mock_psnawpapi.user.return_value.get_presence.return_value, + exception, + ] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (PSNAWPNotFoundError, ConfigEntryState.SETUP_ERROR), + (PSNAWPAuthenticationError, ConfigEntryState.SETUP_ERROR), + (PSNAWPServerError, ConfigEntryState.SETUP_RETRY), + (PSNAWPClientError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_friends_coordinator_setup_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test friends coordinator setup fails in _async_setup.""" + + mock_psnawpapi.user.side_effect = [ + mock_psnawpapi.user.return_value, + exception, + ] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is state + + +async def test_friends_coordinator_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test friends coordinator starts reauth on authentication error.""" + mock_psnawpapi.user.side_effect = [ + mock_psnawpapi.user.return_value, + PSNAWPAuthenticationError, + ] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id diff --git a/tests/components/playstation_network/test_media_player.py b/tests/components/playstation_network/test_media_player.py index f503a5ec297..53bf6436c73 100644 --- a/tests/components/playstation_network/test_media_player.py +++ b/tests/components/playstation_network/test_media_player.py @@ -114,6 +114,76 @@ async def test_platform( """Test setup of the PlayStation Network media_player platform.""" mock_psnawpapi.user().get_presence.return_value = presence_payload + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + "presence_payload", + [ + { + "profile": { + "presences": [ + { + "onlineStatus": "standby", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + ], +) +@pytest.mark.usefixtures("mock_psnawpapi", "mock_token") +async def test_media_player_psvita( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_psnawpapi: MagicMock, + presence_payload: dict[str, Any], +) -> None: + """Test setup of the PlayStation Network media_player for PlayStation Vita.""" + + mock_psnawpapi.user().get_presence.return_value = { + "basicPresence": { + "availability": "unavailable", + "primaryPlatformInfo": {"onlineStatus": "offline", "platform": ""}, + } + } + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = presence_payload config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/playstation_network/test_notify.py b/tests/components/playstation_network/test_notify.py new file mode 100644 index 00000000000..f81e03dfcc4 --- /dev/null +++ b/tests/components/playstation_network/test_notify.py @@ -0,0 +1,132 @@ +"""Tests for the PlayStation Network notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +from freezegun.api import freeze_time +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + "entity_id", + ["notify.testuser_group_publicuniversalfriend", "notify.testuser_direct_message"], +) +@freeze_time("2025-07-28T00:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + entity_id: str, +) -> None: + """Test send message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "2025-07-28T00:00:00+00:00" + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") + + +@pytest.mark.parametrize( + "exception", + [PSNAWPClientError, PSNAWPForbiddenError, PSNAWPNotFoundError, PSNAWPServerError], +) +async def test_send_message_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test send message exceptions.""" + + mock_psnawpapi.group.return_value.send_message.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == STATE_UNKNOWN + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") diff --git a/tests/components/playstation_network/test_sensor.py b/tests/components/playstation_network/test_sensor.py new file mode 100644 index 00000000000..c39f121c912 --- /dev/null +++ b/tests/components/playstation_network/test_sensor.py @@ -0,0 +1,42 @@ +"""Test the Playstation Network sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the PlayStation Network sensor platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index e0a61106101..7120e0f87f0 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -2,11 +2,12 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from munch import Munch from packaging.version import Version import pytest @@ -23,6 +24,14 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +def build_smile(**attrs): + """Build smile Munch from provided attributes.""" + smile = Munch() + for k, v in attrs.items(): + setattr(smile, k, v) + return smile + + def _read_json(environment: str, call: str) -> dict[str, Any]: """Undecode the json data.""" fixture = load_fixture(f"plugwise/{environment}/{call}.json") @@ -106,17 +115,41 @@ def mock_smile_config_flow() -> Generator[MagicMock]: with patch( "homeassistant.components.plugwise.config_flow.Smile", autospec=True, - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.connect.return_value = Version("4.3.2") - smile.smile_hostname = "smile12345" - smile.smile_model = "Test Model" - smile.smile_model_id = "Test Model ID" - smile.smile_name = "Test Smile Name" - smile.smile_version = "4.3.2" + api.connect.return_value = Version("4.3.2") + api.smile = build_smile( + hostname="smile12345", + model="Test Model", + model_id="Test Model ID", + name="Test Smile Name", + version="4.3.2", + ) - yield smile + yield api + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms.""" + return [] + + +@pytest.fixture +async def setup_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms, +) -> AsyncGenerator[None]: + """Set up one or all platforms.""" + + mock_config_entry.add_to_hass(hass) + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry @pytest.fixture @@ -127,28 +160,30 @@ def mock_smile_adam() -> Generator[MagicMock]: with ( patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock, + ) as api_mock, patch( "homeassistant.components.plugwise.config_flow.Smile", - new=smile_mock, + new=api_mock, ), ): - smile = smile_mock.return_value + api = api_mock.return_value - smile.async_update.return_value = data - smile.cooling_present = False - smile.connect.return_value = Version("3.0.15") - smile.gateway_id = "fe799307f1624099878210aa0b9f1475" - smile.heater_id = "90986d591dcd426cae3ec3e8111ff730" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.smile_type = "thermostat" - smile.smile_version = "3.0.15" + api.async_update.return_value = data + api.cooling_present = False + api.connect.return_value = Version("3.0.15") + api.gateway_id = "fe799307f1624099878210aa0b9f1475" + api.heater_id = "90986d591dcd426cae3ec3e8111ff730" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_open_therm", + name="Adam", + type="thermostat", + version="3.0.15", + ) - yield smile + yield api @pytest.fixture @@ -159,23 +194,25 @@ def mock_smile_adam_heat_cool( data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("3.6.4") - smile.cooling_present = cooling_present - smile.gateway_id = "da224107914542988a88561b4452b0f6" - smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.smile_type = "thermostat" - smile.smile_version = "3.6.4" + api.async_update.return_value = data + api.connect.return_value = Version("3.6.4") + api.cooling_present = cooling_present + api.gateway_id = "da224107914542988a88561b4452b0f6" + api.heater_id = "056ee145a816487eaa69243c3280f8bf" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_open_therm", + name="Adam", + type="thermostat", + version="3.6.4", + ) - yield smile + yield api @pytest.fixture @@ -185,23 +222,25 @@ def mock_smile_adam_jip() -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("3.2.8") - smile.cooling_present = False - smile.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" - smile.heater_id = "e4684553153b44afbef2200885f379dc" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.smile_type = "thermostat" - smile.smile_version = "3.2.8" + api.async_update.return_value = data + api.connect.return_value = Version("3.2.8") + api.cooling_present = False + api.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" + api.heater_id = "e4684553153b44afbef2200885f379dc" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_open_therm", + name="Adam", + type="thermostat", + version="3.2.8", + ) - yield smile + yield api @pytest.fixture @@ -210,23 +249,25 @@ def mock_smile_anna(chosen_env: str, cooling_present: bool) -> Generator[MagicMo data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("4.0.15") - smile.cooling_present = cooling_present - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.smile_type = "thermostat" - smile.smile_version = "4.0.15" + api.async_update.return_value = data + api.connect.return_value = Version("4.0.15") + api.cooling_present = cooling_present + api.gateway_id = "015ae9ea3f964e668e490fa39da3870b" + api.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_thermo", + name="Smile Anna", + type="thermostat", + version="4.0.15", + ) - yield smile + yield api @pytest.fixture @@ -235,22 +276,24 @@ def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("4.4.2") - smile.gateway_id = gateway_id - smile.heater_id = None - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile" - smile.smile_name = "Smile P1" - smile.smile_type = "power" - smile.smile_version = "4.4.2" + api.async_update.return_value = data + api.connect.return_value = Version("4.4.2") + api.gateway_id = gateway_id + api.heater_id = None + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile", + name="Smile P1", + type="power", + version="4.4.2", + ) - yield smile + yield api @pytest.fixture @@ -260,22 +303,24 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("1.8.22") - smile.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" - smile.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" - smile.reboot = False - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = None - smile.smile_name = "Smile Anna" - smile.smile_type = "thermostat" - smile.smile_version = "1.8.22" + api.async_update.return_value = data + api.connect.return_value = Version("1.8.22") + api.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" + api.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" + api.reboot = False + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id=None, + name="Smile Anna", + type="thermostat", + version="1.8.22", + ) - yield smile + yield api @pytest.fixture @@ -285,22 +330,24 @@ def mock_stretch() -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("3.1.11") - smile.gateway_id = "259882df3c05415b99c2d962534ce820" - smile.heater_id = None - smile.reboot = False - smile.smile_hostname = "stretch98765" - smile.smile_model = "Gateway" - smile.smile_model_id = None - smile.smile_name = "Stretch" - smile.smile_type = "stretch" - smile.smile_version = "3.1.11" + api.async_update.return_value = data + api.connect.return_value = Version("3.1.11") + api.gateway_id = "259882df3c05415b99c2d962534ce820" + api.heater_id = None + api.reboot = False + api.smile = build_smile( + hostname="stretch98765", + model="Gateway", + model_id=None, + name="Stretch", + type="stretch", + version="3.1.11", + ) - yield smile + yield api @pytest.fixture diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json deleted file mode 100644 index 3a54c3fb9a2..00000000000 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "devices": { - "015ae9ea3f964e668e490fa39da3870b": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.0.15", - "hardware": "AME Smile 2.0 board", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_thermo", - "name": "Smile Anna", - "sensors": { - "outdoor_temperature": 20.2 - }, - "vendor": "Plugwise" - }, - "1cbf783bb11e4a7c8a6843dee3a86927": { - "available": true, - "binary_sensors": { - "compressor_state": true, - "cooling_enabled": false, - "cooling_state": false, - "dhw_state": false, - "flame_state": false, - "heating_state": true, - "secondary_boiler_state": false - }, - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "max_dhw_temperature": { - "lower_bound": 35.0, - "resolution": 0.01, - "setpoint": 53.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 0.0, - "resolution": 1.0, - "setpoint": 60.0, - "upper_bound": 100.0 - }, - "model": "Generic heater/cooler", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 46.3, - "intended_boiler_temperature": 35.0, - "modulation_level": 52, - "outdoor_air_temperature": 3.0, - "return_temperature": 25.1, - "water_pressure": 1.57, - "water_temperature": 29.1 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Techneco" - }, - "3cb70739631c4d17a86b8b12e8a5161b": { - "active_preset": "home", - "available_schedules": ["standaard", "off"], - "climate_mode": "auto", - "control_state": "heating", - "dev_class": "thermostat", - "firmware": "2018-02-08T11:15:53+01:00", - "hardware": "6539-1301-5002", - "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "select_schedule": "standaard", - "sensors": { - "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0, - "illuminance": 86.0, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "temperature": 19.3 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": -0.5, - "upper_bound": 2.0 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 67, - "notifications": {}, - "reboot": true, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/legacy_anna/all_data.json b/tests/components/plugwise/fixtures/legacy_anna/all_data.json deleted file mode 100644 index 9275b82cde9..00000000000 --- a/tests/components/plugwise/fixtures/legacy_anna/all_data.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "devices": { - "0000aaaa0000aaaa0000aaaa0000aa00": { - "dev_class": "gateway", - "firmware": "1.8.22", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "mac_address": "01:23:45:67:89:AB", - "model": "Gateway", - "name": "Smile Anna", - "vendor": "Plugwise" - }, - "04e4cbfe7f4340f090f85ec3b9e6a950": { - "binary_sensors": { - "flame_state": true, - "heating_state": true - }, - "dev_class": "heater_central", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "maximum_boiler_temperature": { - "lower_bound": 50.0, - "resolution": 1.0, - "setpoint": 50.0, - "upper_bound": 90.0 - }, - "model": "Generic heater", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 51.2, - "intended_boiler_temperature": 17.0, - "modulation_level": 0.0, - "return_temperature": 21.7, - "water_pressure": 1.2, - "water_temperature": 23.6 - }, - "vendor": "Bosch Thermotechniek B.V." - }, - "0d266432d64443e283b5d708ae98b455": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "heating", - "dev_class": "thermostat", - "firmware": "2017-03-13T11:54:58+01:00", - "hardware": "6539-1301-500", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], - "sensors": { - "illuminance": 150.8, - "setpoint": 20.5, - "temperature": 20.4 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", - "heater_id": "04e4cbfe7f4340f090f85ec3b9e6a950", - "item_count": 41, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/legacy_anna/data.json b/tests/components/plugwise/fixtures/legacy_anna/data.json index cc7e66fb174..75c12a4c8c2 100644 --- a/tests/components/plugwise/fixtures/legacy_anna/data.json +++ b/tests/components/plugwise/fixtures/legacy_anna/data.json @@ -35,6 +35,7 @@ }, "0d266432d64443e283b5d708ae98b455": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "heating", "dev_class": "thermostat", @@ -44,6 +45,7 @@ "model": "ThermoTouch", "name": "Anna", "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], + "select_schedule": null, "sensors": { "illuminance": 150.8, "setpoint": 20.5, diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json deleted file mode 100644 index af6d4b83380..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "devices": { - "056ee145a816487eaa69243c3280f8bf": { - "available": true, - "binary_sensors": { - "cooling_state": true, - "dhw_state": false, - "flame_state": false, - "heating_state": false - }, - "dev_class": "heater_central", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "maximum_boiler_temperature": { - "lower_bound": 25.0, - "resolution": 0.01, - "setpoint": 50.0, - "upper_bound": 95.0 - }, - "model": "Generic heater", - "name": "OpenTherm", - "sensors": { - "intended_boiler_temperature": 17.5, - "water_temperature": 19.0 - }, - "switches": { - "dhw_cm_switch": false - } - }, - "1772a4ea304041adb83f357b751341ff": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Badkamer", - "sensors": { - "battery": 99, - "setpoint": 18.0, - "temperature": 21.6, - "temperature_difference": -0.2, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C8FF5EE" - }, - "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "available": true, - "dev_class": "thermostat", - "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "ThermoTouch", - "model_id": "143.1", - "name": "Anna", - "sensors": { - "setpoint": 23.5, - "temperature": 25.8 - }, - "vendor": "Plugwise" - }, - "da224107914542988a88561b4452b0f6": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "3.7.8", - "gateway_modes": ["away", "full", "vacation"], - "hardware": "AME Smile 2.0 board", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345679891", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "regulation_modes": [ - "bleeding_hot", - "bleeding_cold", - "off", - "heating", - "cooling" - ], - "select_gateway_mode": "full", - "select_regulation_mode": "cooling", - "sensors": { - "outdoor_temperature": 29.65 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000D5A168D" - }, - "e2f4322d57924fa090fbbc48b3a140dc": { - "available": true, - "binary_sensors": { - "low_battery": true - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-10T02:00:00+02:00", - "hardware": "255", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Lisa", - "model_id": "158-01", - "name": "Lisa Badkamer", - "sensors": { - "battery": 14, - "setpoint": 23.5, - "temperature": 23.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C869B61" - }, - "e8ef2a01ed3b4139a53bf749204fe6b4": { - "dev_class": "switching", - "members": [ - "2568cc4b9c1e401495d4741a5f89bee1", - "29542b2b6a6a4169acecc15c72a599b8" - ], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "f2bf9048bef64cc5b6d5110154e33c81": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "cool", - "control_state": "cooling", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Living room", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", - "sensors": { - "electricity_consumed": 149.9, - "electricity_produced": 0.0, - "temperature": 25.8 - }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 23.5, - "upper_bound": 35.0 - }, - "thermostats": { - "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "f871b8c4d63549319221e294e4f88074": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "auto", - "control_state": "cooling", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Bathroom", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", - "sensors": { - "electricity_consumed": 0.0, - "electricity_produced": 0.0, - "temperature": 23.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 25.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], - "secondary": ["1772a4ea304041adb83f357b751341ff"] - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "da224107914542988a88561b4452b0f6", - "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 89, - "notifications": {}, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json deleted file mode 100644 index bb24faeebfa..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "devices": { - "056ee145a816487eaa69243c3280f8bf": { - "available": true, - "binary_sensors": { - "dhw_state": false, - "flame_state": false, - "heating_state": true - }, - "dev_class": "heater_central", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "max_dhw_temperature": { - "lower_bound": 40.0, - "resolution": 0.01, - "setpoint": 60.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 25.0, - "resolution": 0.01, - "setpoint": 50.0, - "upper_bound": 95.0 - }, - "model": "Generic heater", - "name": "OpenTherm", - "sensors": { - "intended_boiler_temperature": 38.1, - "water_temperature": 37.0 - }, - "switches": { - "dhw_cm_switch": false - } - }, - "1772a4ea304041adb83f357b751341ff": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Badkamer", - "sensors": { - "battery": 99, - "setpoint": 18.0, - "temperature": 18.6, - "temperature_difference": -0.2, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C8FF5EE" - }, - "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "available": true, - "dev_class": "thermostat", - "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "ThermoTouch", - "model_id": "143.1", - "name": "Anna", - "sensors": { - "setpoint": 20.0, - "temperature": 19.1 - }, - "vendor": "Plugwise" - }, - "da224107914542988a88561b4452b0f6": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "3.7.8", - "gateway_modes": ["away", "full", "vacation"], - "hardware": "AME Smile 2.0 board", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345679891", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], - "select_gateway_mode": "full", - "select_regulation_mode": "heating", - "sensors": { - "outdoor_temperature": -1.25 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000D5A168D" - }, - "e2f4322d57924fa090fbbc48b3a140dc": { - "available": true, - "binary_sensors": { - "low_battery": true - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-10T02:00:00+02:00", - "hardware": "255", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Lisa", - "model_id": "158-01", - "name": "Lisa Badkamer", - "sensors": { - "battery": 14, - "setpoint": 15.0, - "temperature": 17.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C869B61" - }, - "e8ef2a01ed3b4139a53bf749204fe6b4": { - "dev_class": "switching", - "members": [ - "2568cc4b9c1e401495d4741a5f89bee1", - "29542b2b6a6a4169acecc15c72a599b8" - ], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "f2bf9048bef64cc5b6d5110154e33c81": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "heat", - "control_state": "preheating", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Living room", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", - "sensors": { - "electricity_consumed": 149.9, - "electricity_produced": 0.0, - "temperature": 19.1 - }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 20.0, - "upper_bound": 35.0 - }, - "thermostats": { - "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "f871b8c4d63549319221e294e4f88074": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Bathroom", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", - "sensors": { - "electricity_consumed": 0.0, - "electricity_produced": 0.0, - "temperature": 17.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], - "secondary": ["1772a4ea304041adb83f357b751341ff"] - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "da224107914542988a88561b4452b0f6", - "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 89, - "notifications": {}, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json deleted file mode 100644 index 1a3ef66c147..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "devices": { - "06aecb3d00354375924f50c47af36bd2": { - "active_preset": "no_frost", - "climate_mode": "off", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Slaapkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 24.2 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["1346fbd8498d4dbcab7e18d51b771f3d"], - "secondary": ["356b65335e274d769c338223e7af9c33"] - }, - "vendor": "Plugwise" - }, - "13228dab8ce04617af318a2888b3c548": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Woonkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 27.4 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.01, - "setpoint": 9.0, - "upper_bound": 30.0 - }, - "thermostats": { - "primary": ["f61f1a2535f54f52ad006a3d18e459ca"], - "secondary": ["833de10f269c4deab58fb9df69901b4e"] - }, - "vendor": "Plugwise" - }, - "1346fbd8498d4dbcab7e18d51b771f3d": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "06aecb3d00354375924f50c47af36bd2", - "model": "Lisa", - "model_id": "158-01", - "name": "Slaapkamer", - "sensors": { - "battery": 92, - "setpoint": 13.0, - "temperature": 24.2 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03" - }, - "1da4d325838e4ad8aac12177214505c9": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "d58fec52899f4f1c92e4f8fad6d8c48c", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Logeerkamer", - "sensors": { - "setpoint": 13.0, - "temperature": 28.8, - "temperature_difference": 2.0, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" - }, - "356b65335e274d769c338223e7af9c33": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "06aecb3d00354375924f50c47af36bd2", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Slaapkamer", - "sensors": { - "setpoint": 13.0, - "temperature": 24.2, - "temperature_difference": 1.7, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05" - }, - "457ce8414de24596a2d5e7dbc9c7682f": { - "available": true, - "dev_class": "zz_misc_plug", - "location": "9e4433a9d69f40b3aefd15e74395eaec", - "model": "Aqara Smart Plug", - "model_id": "lumi.plug.maeu01", - "name": "Plug", - "sensors": { - "electricity_consumed_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": false - }, - "vendor": "LUMI", - "zigbee_mac_address": "ABCD012345670A06" - }, - "6f3e9d7084214c21b9dfa46f6eeb8700": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "d27aede973b54be484f6842d1b2802ad", - "model": "Lisa", - "model_id": "158-01", - "name": "Kinderkamer", - "sensors": { - "battery": 79, - "setpoint": 13.0, - "temperature": 30.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02" - }, - "833de10f269c4deab58fb9df69901b4e": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "13228dab8ce04617af318a2888b3c548", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Woonkamer", - "sensors": { - "setpoint": 9.0, - "temperature": 24.0, - "temperature_difference": 1.8, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A09" - }, - "a6abc6a129ee499c88a4d420cc413b47": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "d58fec52899f4f1c92e4f8fad6d8c48c", - "model": "Lisa", - "model_id": "158-01", - "name": "Logeerkamer", - "sensors": { - "battery": 80, - "setpoint": 13.0, - "temperature": 30.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, - "b5c2386c6f6342669e50fe49dd05b188": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "3.2.8", - "gateway_modes": ["away", "full", "vacation"], - "hardware": "AME Smile 2.0 board", - "location": "9e4433a9d69f40b3aefd15e74395eaec", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], - "select_gateway_mode": "full", - "select_regulation_mode": "heating", - "sensors": { - "outdoor_temperature": 24.9 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" - }, - "d27aede973b54be484f6842d1b2802ad": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Kinderkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 30.0 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["6f3e9d7084214c21b9dfa46f6eeb8700"], - "secondary": ["d4496250d0e942cfa7aea3476e9070d5"] - }, - "vendor": "Plugwise" - }, - "d4496250d0e942cfa7aea3476e9070d5": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "d27aede973b54be484f6842d1b2802ad", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Kinderkamer", - "sensors": { - "setpoint": 13.0, - "temperature": 28.7, - "temperature_difference": 1.9, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" - }, - "d58fec52899f4f1c92e4f8fad6d8c48c": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Logeerkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 30.0 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["a6abc6a129ee499c88a4d420cc413b47"], - "secondary": ["1da4d325838e4ad8aac12177214505c9"] - }, - "vendor": "Plugwise" - }, - "e4684553153b44afbef2200885f379dc": { - "available": true, - "binary_sensors": { - "dhw_state": false, - "flame_state": false, - "heating_state": false - }, - "dev_class": "heater_central", - "location": "9e4433a9d69f40b3aefd15e74395eaec", - "max_dhw_temperature": { - "lower_bound": 40.0, - "resolution": 0.01, - "setpoint": 60.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 20.0, - "resolution": 0.01, - "setpoint": 90.0, - "upper_bound": 90.0 - }, - "model": "Generic heater", - "model_id": "10.20", - "name": "OpenTherm", - "sensors": { - "intended_boiler_temperature": 0.0, - "modulation_level": 0.0, - "return_temperature": 37.1, - "water_pressure": 1.4, - "water_temperature": 37.3 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Remeha B.V." - }, - "f61f1a2535f54f52ad006a3d18e459ca": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermometer", - "firmware": "2020-09-01T02:00:00+02:00", - "hardware": "1", - "location": "13228dab8ce04617af318a2888b3c548", - "model": "Jip", - "model_id": "168-01", - "name": "Woonkamer", - "sensors": { - "battery": 100, - "humidity": 56.2, - "setpoint": 9.0, - "temperature": 27.4 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", - "heater_id": "e4684553153b44afbef2200885f379dc", - "item_count": 244, - "notifications": {}, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_jip/data.json b/tests/components/plugwise/fixtures/m_adam_jip/data.json index 8de57910f66..50b9a8109ee 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/data.json @@ -1,11 +1,13 @@ { "06aecb3d00354375924f50c47af36bd2": { "active_preset": "no_frost", + "available_schedules": [], "climate_mode": "off", "dev_class": "climate", "model": "ThermoZone", "name": "Slaapkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 24.2 }, @@ -23,12 +25,14 @@ }, "13228dab8ce04617af318a2888b3c548": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Woonkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 27.4 }, @@ -236,12 +240,14 @@ }, "d27aede973b54be484f6842d1b2802ad": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Kinderkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 30.0 }, @@ -283,12 +289,14 @@ }, "d58fec52899f4f1c92e4f8fad6d8c48c": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Logeerkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 30.0 }, diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json deleted file mode 100644 index 8da184a7a3e..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json +++ /dev/null @@ -1,594 +0,0 @@ -{ - "devices": { - "02cf28bfec924855854c544690a609ef": { - "available": true, - "dev_class": "vcr_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "NVR", - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A15" - }, - "08963fec7c53423ca5680aa4cb502c63": { - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "Badkamer Schema", - "sensors": { - "temperature": 18.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 14.0, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": [ - "f1fee6043d3642a9b0a65297455f008e", - "680423ff840043738f42cc7f1ff97a36" - ], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "12493538af164a409c6a1c79e38afe1c": { - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Bios", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "off", - "sensors": { - "electricity_consumed": 0.0, - "electricity_produced": 0.0, - "temperature": 16.5 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["df4a4a8169904cdb9c03d61a21f42140"], - "secondary": ["a2c3583e0a6349358998b760cea82d2a"] - }, - "vendor": "Plugwise" - }, - "21f2b542c49845e6bb416884c55778d6": { - "available": true, - "dev_class": "game_console_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "Playstation Smart Plug", - "sensors": { - "electricity_consumed": 84.1, - "electricity_consumed_interval": 8.6, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A12" - }, - "446ac08dd04d4eff8ac57489757b7314": { - "active_preset": "no_frost", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Garage", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 15.6 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 5.5, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["e7693eb9582644e5b865dba8d4447cf1"], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "4a810418d5394b3f82727340b91ba740": { - "available": true, - "dev_class": "router_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "USG Smart Plug", - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A16" - }, - "675416a629f343c495449970e2ca37b5": { - "available": true, - "dev_class": "router_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "Ziggo Modem", - "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, - "680423ff840043738f42cc7f1ff97a36": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Thermostatic Radiator Badkamer 1", - "sensors": { - "battery": 51, - "setpoint": 14.0, - "temperature": 19.1, - "temperature_difference": -0.4, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A17" - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Lisa", - "model_id": "158-01", - "name": "Zone Thermostat Jessie", - "sensors": { - "battery": 37, - "setpoint": 15.0, - "temperature": 17.2 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03" - }, - "78d1126fc4c743db81b61c20e88342a7": { - "available": true, - "dev_class": "central_heating_pump_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Plug", - "model_id": "160-01", - "name": "CV Pomp", - "sensors": { - "electricity_consumed": 35.6, - "electricity_consumed_interval": 7.37, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05" - }, - "82fa13f017d240daa0d0ea1775420f24": { - "active_preset": "asleep", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Jessie", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "CV Jessie", - "sensors": { - "temperature": 17.2 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["6a3bf693d05e48e0b460c815a4fdd09d"], - "secondary": ["d3da73bde12a47d5a6b8f9dad971f2ec"] - }, - "vendor": "Plugwise" - }, - "90986d591dcd426cae3ec3e8111ff730": { - "binary_sensors": { - "heating_state": true - }, - "dev_class": "heater_central", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "model": "Unknown", - "name": "OnOff", - "sensors": { - "intended_boiler_temperature": 70.0, - "modulation_level": 1, - "water_temperature": 70.0 - } - }, - "a28f588dc4a049a483fd03a30361ad3a": { - "available": true, - "dev_class": "settop_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "Fibaro HC2", - "sensors": { - "electricity_consumed": 12.5, - "electricity_consumed_interval": 3.8, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A13" - }, - "a2c3583e0a6349358998b760cea82d2a": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Bios Cv Thermostatic Radiator ", - "sensors": { - "battery": 62, - "setpoint": 13.0, - "temperature": 17.2, - "temperature_difference": -0.2, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A09" - }, - "b310b72a0e354bfab43089919b9a88bf": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Floor kraan", - "sensors": { - "setpoint": 21.5, - "temperature": 26.0, - "temperature_difference": 3.5, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02" - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Lisa", - "model_id": "158-01", - "name": "Zone Lisa WK", - "sensors": { - "battery": 34, - "setpoint": 21.5, - "temperature": 20.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" - }, - "c50f167537524366a5af7aa3942feb1e": { - "active_preset": "home", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "auto", - "control_state": "heating", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Woonkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "GF7 Woonkamer", - "sensors": { - "electricity_consumed": 35.6, - "electricity_produced": 0.0, - "temperature": 20.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 21.5, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["b59bcebaf94b499ea7d46e4a66fb62d8"], - "secondary": ["b310b72a0e354bfab43089919b9a88bf"] - }, - "vendor": "Plugwise" - }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "available": true, - "dev_class": "vcr_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "NAS", - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A14" - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Thermostatic Radiator Jessie", - "sensors": { - "battery": 62, - "setpoint": 15.0, - "temperature": 17.1, - "temperature_difference": 0.1, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A10" - }, - "df4a4a8169904cdb9c03d61a21f42140": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Lisa", - "model_id": "158-01", - "name": "Zone Lisa Bios", - "sensors": { - "battery": 67, - "setpoint": 13.0, - "temperature": 16.5 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A06" - }, - "e7693eb9582644e5b865dba8d4447cf1": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "446ac08dd04d4eff8ac57489757b7314", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "CV Kraan Garage", - "sensors": { - "battery": 68, - "setpoint": 5.5, - "temperature": 15.6, - "temperature_difference": 0.0, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A11" - }, - "f1fee6043d3642a9b0a65297455f008e": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Lisa", - "model_id": "158-01", - "name": "Thermostatic Radiator Badkamer 2", - "sensors": { - "battery": 92, - "setpoint": 14.0, - "temperature": 18.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08" - }, - "fe799307f1624099878210aa0b9f1475": { - "binary_sensors": { - "plugwise_notification": true - }, - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "select_regulation_mode": "heating", - "sensors": { - "outdoor_temperature": 7.81 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "fe799307f1624099878210aa0b9f1475", - "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "item_count": 369, - "notifications": { - "af82e4ccf9c548528166d38e560662a4": { - "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." - } - }, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json index 7c38b1b2197..f1880ba69e1 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -112,12 +112,14 @@ }, "446ac08dd04d4eff8ac57489757b7314": { "active_preset": "no_frost", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 15.6 }, @@ -531,6 +533,19 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A11" }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "02cf28bfec924855854c544690a609ef", + "4a810418d5394b3f82727340b91ba740" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, "f1fee6043d3642a9b0a65297455f008e": { "available": true, "binary_sensors": { @@ -574,7 +589,6 @@ "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." } }, - "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": 7.81 }, diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json deleted file mode 100644 index eaa42facf10..00000000000 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "devices": { - "015ae9ea3f964e668e490fa39da3870b": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.0.15", - "hardware": "AME Smile 2.0 board", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_thermo", - "name": "Smile Anna", - "sensors": { - "outdoor_temperature": 28.2 - }, - "vendor": "Plugwise" - }, - "1cbf783bb11e4a7c8a6843dee3a86927": { - "available": true, - "binary_sensors": { - "compressor_state": true, - "cooling_enabled": true, - "cooling_state": true, - "dhw_state": false, - "flame_state": false, - "heating_state": false, - "secondary_boiler_state": false - }, - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "max_dhw_temperature": { - "lower_bound": 35.0, - "resolution": 0.01, - "setpoint": 53.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 0.0, - "resolution": 1.0, - "setpoint": 60.0, - "upper_bound": 100.0 - }, - "model": "Generic heater/cooler", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 41.5, - "intended_boiler_temperature": 0.0, - "modulation_level": 40, - "outdoor_air_temperature": 28.0, - "return_temperature": 23.8, - "water_pressure": 1.57, - "water_temperature": 22.7 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Techneco" - }, - "3cb70739631c4d17a86b8b12e8a5161b": { - "active_preset": "home", - "available_schedules": ["standaard", "off"], - "climate_mode": "auto", - "control_state": "cooling", - "dev_class": "thermostat", - "firmware": "2018-02-08T11:15:53+01:00", - "hardware": "6539-1301-5002", - "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "select_schedule": "standaard", - "sensors": { - "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0, - "illuminance": 86.0, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "temperature": 26.3 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": -0.5, - "upper_bound": 2.0 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 67, - "notifications": {}, - "reboot": true, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json deleted file mode 100644 index 52645b0f317..00000000000 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "devices": { - "015ae9ea3f964e668e490fa39da3870b": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.0.15", - "hardware": "AME Smile 2.0 board", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_thermo", - "name": "Smile Anna", - "sensors": { - "outdoor_temperature": 28.2 - }, - "vendor": "Plugwise" - }, - "1cbf783bb11e4a7c8a6843dee3a86927": { - "available": true, - "binary_sensors": { - "compressor_state": false, - "cooling_enabled": true, - "cooling_state": false, - "dhw_state": false, - "flame_state": false, - "heating_state": false, - "secondary_boiler_state": false - }, - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "max_dhw_temperature": { - "lower_bound": 35.0, - "resolution": 0.01, - "setpoint": 53.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 0.0, - "resolution": 1.0, - "setpoint": 60.0, - "upper_bound": 100.0 - }, - "model": "Generic heater/cooler", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 46.3, - "intended_boiler_temperature": 18.0, - "modulation_level": 0, - "outdoor_air_temperature": 28.2, - "return_temperature": 22.0, - "water_pressure": 1.57, - "water_temperature": 19.1 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Techneco" - }, - "3cb70739631c4d17a86b8b12e8a5161b": { - "active_preset": "home", - "available_schedules": ["standaard", "off"], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "thermostat", - "firmware": "2018-02-08T11:15:53+01:00", - "hardware": "6539-1301-5002", - "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "select_schedule": "standaard", - "sensors": { - "cooling_activation_outdoor_temperature": 25.0, - "cooling_deactivation_threshold": 4.0, - "illuminance": 86.0, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "temperature": 23.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": -0.5, - "upper_bound": 2.0 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 67, - "notifications": {}, - "reboot": true, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json deleted file mode 100644 index 3ea4bb01be2..00000000000 --- a/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "devices": { - "a455b61e52394b2db5081ce025a430f3": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.4.2", - "hardware": "AME Smile 2.0 board", - "location": "a455b61e52394b2db5081ce025a430f3", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile", - "name": "Smile P1", - "vendor": "Plugwise" - }, - "ba4de7613517478da82dd9b6abea36af": { - "available": true, - "dev_class": "smartmeter", - "location": "a455b61e52394b2db5081ce025a430f3", - "model": "KFM5KAIFA-METER", - "name": "P1", - "sensors": { - "electricity_consumed_off_peak_cumulative": 17643.423, - "electricity_consumed_off_peak_interval": 15, - "electricity_consumed_off_peak_point": 486, - "electricity_consumed_peak_cumulative": 13966.608, - "electricity_consumed_peak_interval": 0, - "electricity_consumed_peak_point": 0, - "electricity_phase_one_consumed": 486, - "electricity_phase_one_produced": 0, - "electricity_produced_off_peak_cumulative": 0.0, - "electricity_produced_off_peak_interval": 0, - "electricity_produced_off_peak_point": 0, - "electricity_produced_peak_cumulative": 0.0, - "electricity_produced_peak_interval": 0, - "electricity_produced_peak_point": 0, - "net_electricity_cumulative": 31610.031, - "net_electricity_point": 486 - }, - "vendor": "SHENZHEN KAIFA TECHNOLOGY \uff08CHENGDU\uff09 CO., LTD." - } - }, - "gateway": { - "gateway_id": "a455b61e52394b2db5081ce025a430f3", - "item_count": 32, - "notifications": {}, - "reboot": true, - "smile_name": "Smile P1" - } -} diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json deleted file mode 100644 index b7476b24a1e..00000000000 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "devices": { - "03e65b16e4b247a29ae0d75a78cb492e": { - "binary_sensors": { - "plugwise_notification": true - }, - "dev_class": "gateway", - "firmware": "4.4.2", - "hardware": "AME Smile 2.0 board", - "location": "03e65b16e4b247a29ae0d75a78cb492e", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile", - "name": "Smile P1", - "vendor": "Plugwise" - }, - "b82b6b3322484f2ea4e25e0bd5f3d61f": { - "available": true, - "dev_class": "smartmeter", - "location": "03e65b16e4b247a29ae0d75a78cb492e", - "model": "XMX5LGF0010453051839", - "name": "P1", - "sensors": { - "electricity_consumed_off_peak_cumulative": 70537.898, - "electricity_consumed_off_peak_interval": 314, - "electricity_consumed_off_peak_point": 5553, - "electricity_consumed_peak_cumulative": 161328.641, - "electricity_consumed_peak_interval": 0, - "electricity_consumed_peak_point": 0, - "electricity_phase_one_consumed": 1763, - "electricity_phase_one_produced": 0, - "electricity_phase_three_consumed": 2080, - "electricity_phase_three_produced": 0, - "electricity_phase_two_consumed": 1703, - "electricity_phase_two_produced": 0, - "electricity_produced_off_peak_cumulative": 0.0, - "electricity_produced_off_peak_interval": 0, - "electricity_produced_off_peak_point": 0, - "electricity_produced_peak_cumulative": 0.0, - "electricity_produced_peak_interval": 0, - "electricity_produced_peak_point": 0, - "gas_consumed_cumulative": 16811.37, - "gas_consumed_interval": 0.06, - "net_electricity_cumulative": 231866.539, - "net_electricity_point": 5553, - "voltage_phase_one": 233.2, - "voltage_phase_three": 234.7, - "voltage_phase_two": 234.4 - }, - "vendor": "XEMEX NV" - } - }, - "gateway": { - "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", - "item_count": 41, - "notifications": { - "97a04c0c263049b29350a660b4cdd01e": { - "warning": "The Smile P1 is not connected to a smart meter." - } - }, - "reboot": true, - "smile_name": "Smile P1" - } -} diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json deleted file mode 100644 index b1675116bdf..00000000000 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "devices": { - "0000aaaa0000aaaa0000aaaa0000aa00": { - "dev_class": "gateway", - "firmware": "3.1.11", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "mac_address": "01:23:45:67:89:AB", - "model": "Gateway", - "name": "Stretch", - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" - }, - "059e4d03c7a34d278add5c7a4a781d19": { - "dev_class": "washingmachine", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Wasmachine (52AC1)", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, - "5871317346d045bc9f6b987ef25ee638": { - "dev_class": "water_heater_vessel", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4028", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Boiler (1EB31)", - "sensors": { - "electricity_consumed": 1.19, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" - }, - "aac7b735042c4832ac9ff33aae4f453b": { - "dev_class": "dishwasher", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4022", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Vaatwasser (2a1ab)", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.71, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02" - }, - "cfe95cf3de1948c0b8955125bf754614": { - "dev_class": "dryer", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Droger (52559)", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" - }, - "d03738edfcc947f7b8f4573571d90d2d": { - "dev_class": "switching", - "members": [ - "059e4d03c7a34d278add5c7a4a781d19", - "cfe95cf3de1948c0b8955125bf754614" - ], - "model": "Switchgroup", - "name": "Schakel", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "d950b314e9d8499f968e6db8d82ef78c": { - "dev_class": "report", - "members": [ - "059e4d03c7a34d278add5c7a4a781d19", - "5871317346d045bc9f6b987ef25ee638", - "aac7b735042c4832ac9ff33aae4f453b", - "cfe95cf3de1948c0b8955125bf754614", - "e1c884e7dede431dadee09506ec4f859" - ], - "model": "Switchgroup", - "name": "Stroomvreters", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "e1c884e7dede431dadee09506ec4f859": { - "dev_class": "refrigerator", - "firmware": "2011-06-27T10:47:37+02:00", - "hardware": "6539-0700-7330", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle+ type F", - "name": "Koelkast (92C4A)", - "sensors": { - "electricity_consumed": 50.5, - "electricity_consumed_interval": 0.08, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "0123456789AB" - } - }, - "gateway": { - "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", - "item_count": 83, - "smile_name": "Stretch" - } -} diff --git a/tests/components/plugwise/snapshots/test_binary_sensor.ambr b/tests/components/plugwise/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..d371bb38803 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_binary_sensor.ambr @@ -0,0 +1,947 @@ +# serializer version: 1 +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.adam_plugwise_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.adam_plugwise_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plugwise notification', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plugwise_notification', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-plugwise_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.adam_plugwise_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'error_msg': list([ + ]), + 'friendly_name': 'Adam Plugwise notification', + 'info_msg': list([ + ]), + 'other_msg': list([ + ]), + 'warning_msg': list([ + "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", + ]), + }), + 'context': , + 'entity_id': 'binary_sensor.adam_plugwise_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.bios_cv_thermostatic_radiator_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bios_cv_thermostatic_radiator_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.bios_cv_thermostatic_radiator_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bios Cv Thermostatic Radiator Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bios_cv_thermostatic_radiator_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.cv_kraan_garage_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.cv_kraan_garage_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.cv_kraan_garage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'CV Kraan Garage Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.cv_kraan_garage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.onoff_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.onoff_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_state', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-heating_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.onoff_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OnOff Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.onoff_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.thermostatic_radiator_badkamer_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '680423ff840043738f42cc7f1ff97a36-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.thermostatic_radiator_badkamer_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.thermostatic_radiator_badkamer_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.thermostatic_radiator_badkamer_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_jessie_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.thermostatic_radiator_jessie_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Jessie Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.thermostatic_radiator_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_bios_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_lisa_bios_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_bios_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa Bios Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_lisa_bios_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_wk_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_lisa_wk_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_wk_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa WK Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_lisa_wk_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_thermostat_jessie_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_thermostat_jessie_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_thermostat_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Thermostat Jessie Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_thermostat_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_compressor_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.opentherm_compressor_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-compressor_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_compressor_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Compressor state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_compressor_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.opentherm_cooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-cooling_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_cooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.opentherm_cooling_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling enabled', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_enabled', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-cooling_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Cooling enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_cooling_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_dhw_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.opentherm_dhw_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DHW state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-dhw_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_dhw_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm DHW state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_dhw_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_flame_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.opentherm_flame_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flame state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flame_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-flame_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_flame_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Flame state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_flame_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.opentherm_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-heating_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_secondary_boiler_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.opentherm_secondary_boiler_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Secondary boiler state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'secondary_boiler_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-secondary_boiler_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_secondary_boiler_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Secondary boiler state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_secondary_boiler_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.smile_anna_plugwise_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smile_anna_plugwise_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plugwise notification', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plugwise_notification', + 'unique_id': '015ae9ea3f964e668e490fa39da3870b-plugwise_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.smile_anna_plugwise_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'error_msg': list([ + ]), + 'friendly_name': 'Smile Anna Plugwise notification', + 'info_msg': list([ + ]), + 'other_msg': list([ + ]), + 'warning_msg': list([ + ]), + }), + 'context': , + 'entity_id': 'binary_sensor.smile_anna_plugwise_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_p1_v4_binary_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][binary_sensor.smile_p1_plugwise_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smile_p1_plugwise_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plugwise notification', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plugwise_notification', + 'unique_id': '03e65b16e4b247a29ae0d75a78cb492e-plugwise_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_p1_v4_binary_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][binary_sensor.smile_p1_plugwise_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'error_msg': list([ + ]), + 'friendly_name': 'Smile P1 Plugwise notification', + 'info_msg': list([ + ]), + 'other_msg': list([ + ]), + 'warning_msg': list([ + 'The Smile P1 is not connected to a smart meter.', + ]), + }), + 'context': , + 'entity_id': 'binary_sensor.smile_p1_plugwise_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/plugwise/snapshots/test_button.ambr b/tests/components/plugwise/snapshots/test_button.ambr new file mode 100644 index 00000000000..900d85db527 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_button.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_adam_button_snapshot[platforms0][button.adam_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.adam_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_button_snapshot[platforms0][button.adam_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Adam Reboot', + }), + 'context': , + 'entity_id': 'button.adam_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 92ed327b841..91411c323ac 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -131,6 +131,8 @@ }), '446ac08dd04d4eff8ac57489757b7314': dict({ 'active_preset': 'no_frost', + 'available_schedules': list([ + ]), 'climate_mode': 'heat', 'control_state': 'idle', 'dev_class': 'climate', @@ -143,6 +145,7 @@ 'vacation', 'no_frost', ]), + 'select_schedule': None, 'sensors': dict({ 'temperature': 15.6, }), @@ -579,6 +582,19 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A11', }), + 'e8ef2a01ed3b4139a53bf749204fe6b4': dict({ + 'dev_class': 'switching', + 'members': list([ + '02cf28bfec924855854c544690a609ef', + '4a810418d5394b3f82727340b91ba740', + ]), + 'model': 'Switchgroup', + 'name': 'Test', + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + }), 'f1fee6043d3642a9b0a65297455f008e': dict({ 'available': True, 'binary_sensors': dict({ @@ -622,7 +638,6 @@ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", }), }), - 'select_regulation_mode': 'heating', 'sensors': dict({ 'outdoor_temperature': 7.81, }), diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 7bf475086af..c01da5c5205 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -3,36 +3,43 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_binary_sensor_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam binary_sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -@pytest.mark.parametrize( - ("entity_id", "expected_state"), - [ - ("binary_sensor.opentherm_secondary_boiler_state", STATE_OFF), - ("binary_sensor.opentherm_dhw_state", STATE_OFF), - ("binary_sensor.opentherm_heating", STATE_ON), - ("binary_sensor.opentherm_cooling_enabled", STATE_OFF), - ("binary_sensor.opentherm_compressor_state", STATE_ON), - ], -) -async def test_anna_climate_binary_sensor_entities( +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_binary_sensor_snapshot( hass: HomeAssistant, mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, - entity_id: str, - expected_state: str, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of climate related binary_sensor entities.""" - state = hass.states.get(entity_id) - assert state.state == expected_state + """Test Anna binary_sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @@ -49,35 +56,23 @@ async def test_anna_climate_binary_sensor_change( assert state.state == STATE_ON await async_update_entity(hass, "binary_sensor.opentherm_dhw_state") - state = hass.states.get("binary_sensor.opentherm_dhw_state") assert state assert state.state == STATE_OFF -async def test_adam_climate_binary_sensor_change( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test of a climate related plugwise-notification binary_sensor.""" - state = hass.states.get("binary_sensor.adam_plugwise_notification") - assert state - assert state.state == STATE_ON - assert "warning_msg" in state.attributes - assert "unreachable" in state.attributes["warning_msg"][0] - assert not state.attributes.get("error_msg") - assert not state.attributes.get("other_msg") - - @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True ) -async def test_p1_binary_sensor_entity( - hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_p1_v4_binary_sensor_snapshot( + hass: HomeAssistant, + mock_smile_p1: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test of a Smile P1 related plugwise-notification binary_sensor.""" - state = hass.states.get("binary_sensor.smile_p1_plugwise_notification") - assert state - assert state.state == STATE_ON - assert "warning_msg" in state.attributes - assert "connected" in state.attributes["warning_msg"][0] + """Test Smile P1 binary_sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/plugwise/test_button.py b/tests/components/plugwise/test_button.py index 23003b3ffe6..8667e2ef893 100644 --- a/tests/components/plugwise/test_button.py +++ b/tests/components/plugwise/test_button.py @@ -2,32 +2,34 @@ from unittest.mock import MagicMock -from homeassistant.components.button import ( - DOMAIN as BUTTON_DOMAIN, - SERVICE_PRESS, - ButtonDeviceClass, -) -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_adam_reboot_button( +@pytest.mark.parametrize("platforms", [(BUTTON_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_button_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam button snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +async def test_adam_press_reboot_button( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test creation of button entities.""" - state = hass.states.get("button.adam_reboot") - assert state - assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - - registry = er.async_get(hass) - entry = registry.async_get("button.adam_reboot") - assert entry - assert entry.unique_id == "fe799307f1624099878210aa0b9f1475-reboot" - + """Test pressing of button entity.""" await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 3787cbf7150..b8554f9a5cc 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -433,13 +433,16 @@ async def test_anna_climate_entity_climate_changes( "c784ee9fdab44e1395b8dee7d7a497d5", HVACMode.OFF ) + # Mock user deleting last schedule from app or browser data = mock_smile_anna.async_update.return_value - data["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") + data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = [] + data["3cb70739631c4d17a86b8b12e8a5161b"]["select_schedule"] = None + data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat_cool" with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("climate.anna") - assert state.state == HVACMode.HEAT + assert state.state == HVACMode.HEAT_COOL assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 16af7065c49..79a5a366f17 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -478,7 +478,7 @@ async def test_reconfigure_flow_smile_mismatch( mock_config_entry: MockConfigEntry, ) -> None: """Test reconfigure flow aborts on other Smile ID.""" - mock_smile_adam.smile_hostname = TEST_SMILE_HOST + mock_smile_adam.smile.hostname = TEST_SMILE_HOST result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) diff --git a/tests/components/progettihwsw/test_config_flow.py b/tests/components/progettihwsw/test_config_flow.py index 8dcc6917346..c41c88ec950 100644 --- a/tests/components/progettihwsw/test_config_flow.py +++ b/tests/components/progettihwsw/test_config_flow.py @@ -12,9 +12,9 @@ from tests.common import MockConfigEntry mock_value_step_user = { "title": "1R & 1IN Board", - "relay_count": 1, - "input_count": 1, - "is_old": False, + "relays": 1, + "inputs": 1, + "temps": False, } diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index e9340014207..22783c0598a 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -871,7 +871,7 @@ async def test_sensor_unique_ids( assert entity assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" state = hass.states.get(sensor_t1) - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Home Test tracker 1 Distance" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Home Test tracker 1 distance" entity = entity_registry.async_get("sensor.home_test2_distance") assert entity diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index 737cc3c9f1b..af1f09d7d73 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -33,8 +33,8 @@ from homeassistant.const import ( CONF_REGION, CONF_TOKEN, STATE_IDLE, + STATE_OFF, STATE_PLAYING, - STATE_STANDBY, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -188,7 +188,7 @@ async def test_state_standby_is_set(hass: HomeAssistant) -> None: await mock_ddp_response(hass, MOCK_STATUS_STANDBY) - assert hass.states.get(mock_entity_id).state == STATE_STANDBY + assert hass.states.get(mock_entity_id).state == STATE_OFF async def test_state_playing_is_set(hass: HomeAssistant) -> None: @@ -308,7 +308,7 @@ async def test_device_info_is_set_from_status_correctly( mock_d_entries = device_registry.devices mock_entry = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_HOST_ID)}) - assert mock_state == STATE_STANDBY + assert mock_state == STATE_OFF assert len(mock_d_entries) == 1 assert mock_entry.name == MOCK_HOST_NAME diff --git a/tests/components/pushbullet/test_sensor.py b/tests/components/pushbullet/test_sensor.py new file mode 100644 index 00000000000..b6ae8c3a211 --- /dev/null +++ b/tests/components/pushbullet/test_sensor.py @@ -0,0 +1,168 @@ +"""Test pushbullet sensor platform.""" + +from unittest.mock import Mock + +import pytest + +from homeassistant.components.pushbullet.const import DOMAIN +from homeassistant.components.pushbullet.sensor import ( + SENSOR_TYPES, + PushBulletNotificationSensor, +) +from homeassistant.const import MAX_LENGTH_STATE_STATE +from homeassistant.core import HomeAssistant + +from . import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +def _create_mock_provider() -> Mock: + """Create a mock pushbullet provider for testing.""" + mock_provider = Mock() + mock_provider.pushbullet.user_info = {"iden": "test_user_123"} + return mock_provider + + +def _get_sensor_description(key: str): + """Get sensor description by key.""" + for desc in SENSOR_TYPES: + if desc.key == key: + return desc + raise ValueError(f"Sensor description not found for key: {key}") + + +def _create_test_sensor( + provider: Mock, sensor_key: str +) -> PushBulletNotificationSensor: + """Create a test sensor instance with mocked dependencies.""" + description = _get_sensor_description(sensor_key) + sensor = PushBulletNotificationSensor( + name="Test Pushbullet", pb_provider=provider, description=description + ) + # Mock async_write_ha_state to avoid requiring full HA setup + sensor.async_write_ha_state = Mock() + return sensor + + +@pytest.fixture +async def mock_pushbullet_entry(hass: HomeAssistant, requests_mock_fixture): + """Set up pushbullet integration.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def test_sensor_truncation_logic() -> None: + """Test sensor truncation logic for body sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test long body truncation + long_body = "a" * (MAX_LENGTH_STATE_STATE + 50) + provider.data = { + "body": long_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("a") + assert sensor._attr_extra_state_attributes["body"] == long_body + + # Test normal length body + normal_body = "This is a normal body" + provider.data = { + "body": normal_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation + assert sensor._attr_native_value == normal_body + assert len(sensor._attr_native_value) < MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == normal_body + + # Test exactly max length + exact_body = "a" * MAX_LENGTH_STATE_STATE + provider.data = { + "body": exact_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation at the limit + assert sensor._attr_native_value == exact_body + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == exact_body + + +def test_sensor_truncation_title_sensor() -> None: + """Test sensor truncation logic on title sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "title") + + # Test long title truncation + long_title = "Title " + "x" * (MAX_LENGTH_STATE_STATE) + provider.data = { + "body": "Test body", + "title": long_title, + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("Title") + assert sensor._attr_extra_state_attributes["title"] == long_title + + +def test_sensor_truncation_non_string_handling() -> None: + """Test that non-string values are handled correctly.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test with None value + provider.data = { + "body": None, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value is None + + # Test with integer value (would be converted to string by Home Assistant) + provider.data = { + "body": 12345, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value == 12345 # Not truncated since it's not a string + + # Test with missing key + provider.data = { + "title": "Test Title", + "type": "note", + } + + # This should not raise an exception + sensor.async_update_callback() diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 9b410a5fdd6..72fabfa3de1 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from tests.common import MockConfigEntry @@ -39,6 +40,21 @@ NEW_INPUT = { } +ADDON_DISCOVERY_INFO = { + "addon": "pyLoad-ng", + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", +} + +ADDON_SERVICE_INFO = HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="pyLoad-ng Addon", + slug="p539df76c_pyload-ng", + uuid="1234", +) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" diff --git a/tests/components/pyload/test_config_flow.py b/tests/components/pyload/test_config_flow.py index 492e4a4b652..1eafbd2eb66 100644 --- a/tests/components/pyload/test_config_flow.py +++ b/tests/components/pyload/test_config_flow.py @@ -6,11 +6,18 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT +from .conftest import ( + ADDON_DISCOVERY_INFO, + ADDON_SERVICE_INFO, + NEW_INPUT, + REAUTH_INPUT, + USER_INPUT, +) from tests.common import MockConfigEntry @@ -245,3 +252,183 @@ async def test_reconfigure_errors( assert result["reason"] == "reconfigure_successful" assert config_entry.data == USER_INPUT assert len(hass.config_entries.async_entries()) == 1 + + +async def test_hassio_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = InvalidAuth + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_confirm_only( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery. Abort with confirm only.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_hassio_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_already_configured( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_data_update( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured and we update entry from discovery data.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + unique_id="1234", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_URL] == "http://539df76c-pyload-ng:8000/" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_ignored( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if discovery was ignored.""" + + MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IGNORE, + data={}, + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py index f1fd96c321b..e1febea524b 100644 --- a/tests/components/qbus/conftest.py +++ b/tests/components/qbus/conftest.py @@ -1,10 +1,13 @@ """Test fixtures for qbus.""" +from collections.abc import Awaitable, Callable, Generator import json +from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.qbus.entity import QbusEntity from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonObjectType @@ -16,6 +19,7 @@ from tests.common import ( async_fire_mqtt_message, load_json_object_fixture, ) +from tests.typing import MqttMockHAClient @pytest.fixture @@ -39,9 +43,17 @@ def payload_config() -> JsonObjectType: return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) +@pytest.fixture +def mock_publish_state() -> Generator[AsyncMock]: + """Return a mocked publish state call.""" + with patch.object(QbusEntity, "_async_publish_output_state") as mock: + yield mock + + @pytest.fixture async def setup_integration( hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, mock_config_entry: MockConfigEntry, payload_config: JsonObjectType, ) -> None: @@ -52,3 +64,22 @@ async def setup_integration( async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) await hass.async_block_till_done() + + +@pytest.fixture +async def setup_integration_deferred( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> Callable[[], Awaitable]: + """Set up the integration.""" + + async def run() -> None: + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + return run diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index 3a9e845bc26..883eca19276 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -112,6 +112,411 @@ "active": null }, "properties": {} + }, + { + "id": "UL30", + "location": "Guest bedroom", + "locationId": 3, + "name": "CURTAINS", + "originalName": "CURTAINS", + "refId": "000001/108", + "type": "shutter", + "actions": { + "shutterDown": null, + "shutterStop": null, + "shutterUp": null + }, + "properties": { + "state": { + "enumValues": ["up", "stop", "down"], + "read": true, + "type": "enumString", + "write": false + } + } + }, + { + "actions": { + "shutterDown": null, + "shutterUp": null, + "slatDown": null, + "slatUp": null + }, + "id": "UL31", + "location": "Living", + "locationId": 0, + "name": "SLATS", + "originalName": "SLATS", + "properties": { + "shutterPosition": { + "read": true, + "step": 0.10000000000000001, + "type": "percent", + "write": true + }, + "slatPosition": { + "read": true, + "step": 0.10000000000000001, + "type": "percent", + "write": true + } + }, + "refId": "000001/8", + "type": "shutter" + }, + { + "actions": { + "shutterDown": null, + "shutterUp": null + }, + "id": "UL32", + "location": "Kitchen", + "locationId": 8, + "name": "BLINDS", + "originalName": "BLINDS", + "properties": { + "shutterPosition": { + "read": true, + "type": "percent", + "write": true + } + }, + "refId": "000001/4", + "type": "shutter" + }, + { + "id": "UL40", + "location": "Tuin", + "locationId": 12, + "name": "Luchtdruk", + "originalName": "Luchtdruk", + "refId": "000001/81", + "type": "gauge", + "variant": "AirPressure", + "actions": {}, + "properties": { + "currentValue": { + "max": 1500, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "mbar", + "write": false + } + } + }, + { + "id": "UL41", + "location": "Tuin", + "locationId": 12, + "name": "Luchtkwaliteit", + "originalName": "Luchtkwaliteit", + "refId": "000001/82", + "type": "gauge", + "variant": "AirQuality", + "actions": {}, + "properties": { + "currentValue": { + "max": 1500, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "ppm", + "write": false + } + } + }, + { + "id": "UL42", + "location": "Garage", + "locationId": 27, + "name": "Stroom", + "originalName": "Stroom", + "refId": "000001/83", + "type": "gauge", + "variant": "Current", + "actions": {}, + "properties": { + "currentValue": { + "max": 100, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "kWh", + "write": false + } + } + }, + { + "id": "UL43", + "location": "Garage", + "locationId": 27, + "name": "Energie", + "originalName": "Energie", + "refId": "000001/84", + "type": "gauge", + "variant": "Energy", + "actions": {}, + "properties": { + "currentValue": { + "read": true, + "step": 0.1, + "type": "number", + "unit": "A", + "write": false + } + } + }, + { + "id": "UL44", + "location": "Garage", + "locationId": 27, + "name": "Gas", + "originalName": "Gas", + "refId": "000001/85", + "type": "gauge", + "variant": "Gas", + "actions": {}, + "properties": { + "currentValue": { + "max": 5, + "min": 0, + "read": true, + "step": 0.001, + "type": "number", + "unit": "m³/h", + "write": false + } + } + }, + { + "id": "UL45", + "location": "Garage", + "locationId": 27, + "name": "Gas flow", + "originalName": "Gas flow", + "refId": "000001/86", + "type": "gauge", + "variant": "GasFlow", + "actions": {}, + "properties": { + "currentValue": { + "max": 10, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "m³/h", + "write": false + } + } + }, + { + "id": "UL46", + "location": "Living", + "locationId": 0, + "name": "Vochtigheid living", + "originalName": "Vochtigheid living", + "refId": "000001/87", + "type": "gauge", + "variant": "Humidity", + "actions": {}, + "properties": { + "currentValue": { + "max": 100, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "%", + "write": false + } + } + }, + { + "id": "UL47", + "location": "Tuin", + "locationId": 12, + "name": "Lichtsterkte tuin", + "originalName": "Lichtsterkte tuin", + "refId": "000001/88", + "type": "gauge", + "variant": "Light", + "actions": {}, + "properties": { + "currentValue": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "lx", + "write": false + } + } + }, + { + "id": "UL40", + "location": "Tuin", + "locationId": 12, + "name": "Regenput", + "originalName": "Regenput", + "refId": "000001/40", + "type": "gauge", + "variant": "WaterLevel", + "actions": {}, + "properties": { + "currentValue": { + "max": 100, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "m", + "write": false + } + } + }, + { + "id": "UL60", + "location": "Tuin", + "locationId": 12, + "name": "Weersensor", + "originalName": "Weersensor", + "refId": "000001/21007", + "type": "weatherstation", + "variant": [null], + "actions": {}, + "properties": { + "dayLight": { + "max": 1000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "light": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "lightEast": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "lightSouth": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "lightWest": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "raining": { + "read": true, + "type": "boolean", + "write": false + }, + "temperature": { + "max": 100, + "min": -100, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "twilight": { + "read": true, + "type": "boolean", + "write": false + }, + "wind": { + "max": 1000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + } + } + }, + { + "id": "UL70", + "location": "", + "locationId": 0, + "name": "Luchtsensor", + "originalName": "Luchtsensor", + "refId": "000001/224", + "type": "ventilation", + "variant": [null], + "actions": {}, + "properties": { + "co2": { + "max": 5000, + "min": 0, + "read": true, + "step": 16, + "type": "number", + "unit": "ppm", + "write": false + }, + "currRegime": { + "enumValues": ["Manueel", "Nacht", "Boost", "Uit", "Auto"], + "read": true, + "type": "enumString", + "write": true + }, + "refresh": { + "max": 100, + "min": 0, + "read": true, + "step": 1, + "type": "number", + "write": true + } + } + }, + { + "id": "UL80", + "location": "Kitchen", + "locationId": 8, + "name": "Vochtigheid keuken", + "originalName": "Vochtigheid keuken", + "properties": { + "currRegime": { + "enumValues": ["Manual", "Cook", "Boost", "Off", "Auto"], + "read": true, + "type": "enumString", + "write": true + }, + "value": { + "read": true, + "step": 1, + "type": "percent", + "write": false + } + }, + "refId": "000001/94/1", + "type": "humidity" } ] } diff --git a/tests/components/qbus/snapshots/test_binary_sensor.ambr b/tests/components/qbus/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..79b36db6639 --- /dev/null +++ b/tests/components/qbus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,146 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.ctd_000001-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ctd_000001', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.ctd_000001-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'CTD 000001', + }), + 'context': , + 'entity_id': 'binary_sensor.ctd_000001', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_raining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.weersensor_raining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Raining', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'raining', + 'unique_id': 'ctd_000001_21007_raining', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_raining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weersensor Raining', + }), + 'context': , + 'entity_id': 'binary_sensor.weersensor_raining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_twilight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.weersensor_twilight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Twilight', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'twilight', + 'unique_id': 'ctd_000001_21007_twilight', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_twilight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weersensor Twilight', + }), + 'context': , + 'entity_id': 'binary_sensor.weersensor_twilight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/qbus/snapshots/test_sensor.ambr b/tests/components/qbus/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fe665057b1e --- /dev/null +++ b/tests/components/qbus/snapshots/test_sensor.ambr @@ -0,0 +1,1047 @@ +# serializer version: 1 +# name: test_sensor[sensor.energie-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energie', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_84', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.energie-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energie', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energie', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.gas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_85', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.gas_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_86', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.gas_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gas Flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.lichtsterkte_tuin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lichtsterkte_tuin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_88', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.lichtsterkte_tuin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Lichtsterkte Tuin', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.lichtsterkte_tuin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.living_th_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_th_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_120', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.living_th_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Th Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_th_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.luchtdruk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luchtdruk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_81', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.luchtdruk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Luchtdruk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luchtdruk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.luchtkwaliteit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luchtkwaliteit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_82', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensor[sensor.luchtkwaliteit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Luchtkwaliteit', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.luchtkwaliteit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.luchtsensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luchtsensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_224', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensor[sensor.luchtsensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Luchtsensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.luchtsensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.regenput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.regenput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_40', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.regenput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Regenput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.regenput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.stroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stroom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_83', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.stroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Stroom', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.vochtigheid_keuken-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vochtigheid_keuken', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_94-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.vochtigheid_keuken-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Vochtigheid Keuken', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vochtigheid_keuken', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.vochtigheid_living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vochtigheid_living', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_87', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.vochtigheid_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Vochtigheid Living', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vochtigheid_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weersensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_21007_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.weersensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Weersensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weersensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_daylight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weersensor_daylight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daylight', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daylight', + 'unique_id': 'ctd_000001_21007_daylight', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_daylight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Daylight', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_daylight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weersensor_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_21007_light', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_east-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weersensor_illuminance_east', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance east', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_east', + 'unique_id': 'ctd_000001_21007_light_east', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_east-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance east', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance_east', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_south-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weersensor_illuminance_south', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance south', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_south', + 'unique_id': 'ctd_000001_21007_light_south', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_south-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance south', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance_south', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_west-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weersensor_illuminance_west', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance west', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_west', + 'unique_id': 'ctd_000001_21007_light_west', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_west-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance west', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance_west', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weersensor_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_21007_wind', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.weersensor_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Weersensor Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weersensor_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/qbus/test_binary_sensor.py b/tests/components/qbus/test_binary_sensor.py new file mode 100644 index 00000000000..9160bdb916e --- /dev/null +++ b/tests/components/qbus/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Test Qbus binary sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_binary_sensor( + hass: HomeAssistant, + setup_integration_deferred: Callable[[], Awaitable], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test binary sensor.""" + + with patch("homeassistant.components.qbus.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration_deferred() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/qbus/test_cover.py b/tests/components/qbus/test_cover.py new file mode 100644 index 00000000000..724be5cb280 --- /dev/null +++ b/tests/components/qbus/test_cover.py @@ -0,0 +1,301 @@ +"""Test Qbus cover entities.""" + +from unittest.mock import AsyncMock + +from qbusmqttapi.state import QbusMqttShutterState + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, +) +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message + +_PAYLOAD_UDS_STATE_CLOSED = '{"id":"UL30","properties":{"state":"down"},"type":"state"}' +_PAYLOAD_UDS_STATE_OPENED = '{"id":"UL30","properties":{"state":"up"},"type":"state"}' +_PAYLOAD_UDS_STATE_STOPPED = ( + '{"id":"UL30","properties":{"state":"stop"},"type":"state"}' +) + +_PAYLOAD_POS_STATE_CLOSED = ( + '{"id":"UL32","properties":{"shutterPosition":0},"type":"event"}' +) +_PAYLOAD_POS_STATE_OPENED = ( + '{"id":"UL32","properties":{"shutterPosition":100},"type":"event"}' +) +_PAYLOAD_POS_STATE_POSITION = ( + '{"id":"UL32","properties":{"shutterPosition":50},"type":"event"}' +) + +_PAYLOAD_SLAT_STATE_CLOSED = ( + '{"id":"UL31","properties":{"slatPosition":0},"type":"event"}' +) +_PAYLOAD_SLAT_STATE_FULLY_CLOSED = ( + '{"id":"UL31","properties":{"slatPosition":0,"shutterPosition":0},"type":"event"}' +) +_PAYLOAD_SLAT_STATE_OPENED = ( + '{"id":"UL31","properties":{"slatPosition":50},"type":"event"}' +) +_PAYLOAD_SLAT_STATE_POSITION = ( + '{"id":"UL31","properties":{"slatPosition":75},"type":"event"}' +) + +_TOPIC_UDS_STATE = "cloudapp/QBUSMQTTGW/UL1/UL30/state" +_TOPIC_POS_STATE = "cloudapp/QBUSMQTTGW/UL1/UL32/state" +_TOPIC_SLAT_STATE = "cloudapp/QBUSMQTTGW/UL1/UL31/state" + +_ENTITY_ID_UDS = "cover.curtains" +_ENTITY_ID_POS = "cover.blinds" +_ENTITY_ID_SLAT = "cover.slats" + + +async def test_cover_up_down_stop( + hass: HomeAssistant, setup_integration: None, mock_publish_state: AsyncMock +) -> None: + """Test cover up, down and stop.""" + + attributes = hass.states.get(_ENTITY_ID_UDS).attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + # Cover open + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_UDS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_state() == "up" + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_UDS_STATE, _PAYLOAD_UDS_STATE_OPENED) + await hass.async_block_till_done() + + assert hass.states.get(_ENTITY_ID_UDS).state == CoverState.OPEN + + # Cover close + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_UDS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_state() == "down" + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_UDS_STATE, _PAYLOAD_UDS_STATE_CLOSED) + await hass.async_block_till_done() + + assert hass.states.get(_ENTITY_ID_UDS).state == CoverState.OPEN + + # Cover stop + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_UDS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_state() == "stop" + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_UDS_STATE, _PAYLOAD_UDS_STATE_STOPPED) + await hass.async_block_till_done() + + assert hass.states.get(_ENTITY_ID_UDS).state == CoverState.CLOSED + + +async def test_cover_position( + hass: HomeAssistant, setup_integration: None, mock_publish_state: AsyncMock +) -> None: + """Test cover positions.""" + + attributes = hass.states.get(_ENTITY_ID_POS).attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + + # Cover open + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_POS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 100 + + async_fire_mqtt_message(hass, _TOPIC_POS_STATE, _PAYLOAD_POS_STATE_OPENED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_POS) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 100 + + # Cover position + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: _ENTITY_ID_POS, ATTR_POSITION: 50}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 50 + + async_fire_mqtt_message(hass, _TOPIC_POS_STATE, _PAYLOAD_POS_STATE_POSITION) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_POS) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 50 + + # Cover close + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_POS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 0 + + async_fire_mqtt_message(hass, _TOPIC_POS_STATE, _PAYLOAD_POS_STATE_CLOSED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_POS) + assert entity_state.state == CoverState.CLOSED + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 0 + + +async def test_cover_slats( + hass: HomeAssistant, setup_integration: None, mock_publish_state: AsyncMock +) -> None: + """Test cover slats.""" + + attributes = hass.states.get(_ENTITY_ID_SLAT).attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + # Start with a fully closed cover + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 0 + assert publish_state.read_slat_position() == 0 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_FULLY_CLOSED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.CLOSED + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 0 + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Slat open + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_slat_position() == 50 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_OPENED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + # SLat position + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT, ATTR_TILT_POSITION: 75}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_slat_position() == 75 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_POSITION) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 75 + + # Slat close + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_slat_position() == 0 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_CLOSED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.CLOSED + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + +def _get_publish_state(mock_publish_state: AsyncMock) -> QbusMqttShutterState: + assert mock_publish_state.call_count == 1 + state = mock_publish_state.call_args.args[0] + assert isinstance(state, QbusMqttShutterState) + return state diff --git a/tests/components/qbus/test_sensor.py b/tests/components/qbus/test_sensor.py new file mode 100644 index 00000000000..255b29eb7f0 --- /dev/null +++ b/tests/components/qbus/test_sensor.py @@ -0,0 +1,27 @@ +"""Test Qbus sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor( + hass: HomeAssistant, + setup_integration_deferred: Callable[[], Awaitable], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor.""" + + with patch("homeassistant.components.qbus.PLATFORMS", [Platform.SENSOR]): + await setup_integration_deferred() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 01e0c4458e4..520f8578c6e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -449,3 +449,75 @@ async def test_fix_duplicate_device_ids( assert device_entry.identifiers == {(DOMAIN, MAC_ADDRESS_UNIQUE_ID)} assert device_entry.name_by_user == expected_device_name assert device_entry.disabled_by == expected_disabled_by + + +async def test_reload_migration_with_leading_zero_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration and reload of a device with a mac address with a leading zero.""" + mac_address = "01:02:03:04:05:06" + mac_address_unique_id = dr.format_mac(mac_address) + serial_number = "0" + + # Setup the config entry to be in a pre-migrated state + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + data={ + "host": "127.0.0.1", + "password": "password", + CONF_MAC: mac_address, + "serial_number": serial_number, + }, + ) + config_entry.add_to_hass(hass) + + # Create a device and entity with the old unique id format + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{serial_number}-1")}, + ) + entity_entry = entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{serial_number}-1-zone1", + suggested_object_id="zone1", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Setup the integration, which will migrate the unique ids + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity were migrated to the new format + migrated_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mac_address_unique_id}-1")} + ) + assert migrated_device_entry is not None + migrated_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert migrated_entity_entry is not None + assert migrated_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + # Reload the integration + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity still have the correct identifiers and were not duplicated + reloaded_device_entry = device_registry.async_get(migrated_device_entry.id) + assert reloaded_device_entry is not None + assert reloaded_device_entry.identifiers == {(DOMAIN, f"{mac_address_unique_id}-1")} + reloaded_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert reloaded_entity_entry is not None + assert reloaded_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 1 + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index 8a143f9963f..9cc89cfcc9e 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -22,7 +22,6 @@ 'abcdef0123456789', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Rainforest Automation, Inc.', @@ -32,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.0.0 (7400)', 'via_device_id': None, }), diff --git a/tests/components/rehlko/test_config_flow.py b/tests/components/rehlko/test_config_flow.py index 6e3400941ab..661b66e789d 100644 --- a/tests/components/rehlko/test_config_flow.py +++ b/tests/components/rehlko/test_config_flow.py @@ -19,7 +19,7 @@ from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="KohlerGen", - macaddress="00146FAABBCC", + macaddress="00146faabbcc", ) diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index b4dd513c317..c5ba1c77c31 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -125,7 +125,12 @@ async def test_get_condition_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( @@ -155,7 +160,12 @@ async def test_get_condition_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index 800d090fd7b..0321ba8bbaa 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -125,7 +125,12 @@ async def test_get_trigger_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( @@ -155,7 +160,12 @@ async def test_get_trigger_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py index 9aff1594db3..9bea46ab27e 100644 --- a/tests/components/remote_calendar/test_config_flow.py +++ b/tests/components/remote_calendar/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Remote Calendar config flow.""" -from httpx import ConnectError, Response, UnsupportedProtocol +from httpx import HTTPError, InvalidURL, Response, TimeoutException import pytest import respx @@ -75,10 +75,11 @@ async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None @pytest.mark.parametrize( - ("side_effect"), + ("side_effect", "base_error"), [ - ConnectError("Connection failed"), - UnsupportedProtocol("Unsupported protocol"), + (TimeoutException("Connection timed out"), "timeout_connect"), + (HTTPError("Connection failed"), "cannot_connect"), + (InvalidURL("Unsupported protocol"), "cannot_connect"), ], ) @respx.mock @@ -86,6 +87,7 @@ async def test_form_inavild_url( hass: HomeAssistant, side_effect: Exception, ics_content: str, + base_error: str, ) -> None: """Test we get the import form.""" result = await hass.config_entries.flow.async_init( @@ -102,7 +104,7 @@ async def test_form_inavild_url( }, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": base_error} respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py index f4ca500b2e1..d3e6b439805 100644 --- a/tests/components/remote_calendar/test_init.py +++ b/tests/components/remote_calendar/test_init.py @@ -1,6 +1,6 @@ """Tests for init platform of Remote Calendar.""" -from httpx import ConnectError, Response, UnsupportedProtocol +from httpx import HTTPError, InvalidURL, Response, TimeoutException import pytest import respx @@ -56,8 +56,9 @@ async def test_raise_for_status( @pytest.mark.parametrize( "side_effect", [ - ConnectError("Connection failed"), - UnsupportedProtocol("Unsupported protocol"), + TimeoutException("Connection timed out"), + HTTPError("Connection failed"), + InvalidURL("Unsupported protocol"), ValueError("Invalid response"), ], ) diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr index 9a10083b227..15b3c599711 100644 --- a/tests/components/renault/snapshots/test_init.ambr +++ b/tests/components/renault/snapshots/test_init.ambr @@ -18,7 +18,6 @@ 'VF1CAPTURFUELVIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -53,7 +51,6 @@ 'VF1CAPTURPHEVVIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -63,7 +60,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -88,7 +84,6 @@ 'VF1TWINGOIIIVIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -98,7 +93,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -123,7 +117,6 @@ 'VF1ZOE40VIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -133,7 +126,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -158,7 +150,6 @@ 'VF1ZOE50VIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -168,7 +159,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2f37fca251a..f8134a515e0 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -1,11 +1,10 @@ """Setup the Reolink tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.api import Chime -from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -44,7 +43,6 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_CAM_NAME = "test_reolink_cam" TEST_NVR_NAME2 = "test2_reolink_name" -TEST_CAM_NAME = "test_reolink_cam" TEST_USE_HTTPS = True TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" @@ -67,6 +65,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.get_host_data = AsyncMock(return_value=None) host_mock.get_states = AsyncMock(return_value=None) host_mock.get_state = AsyncMock() + host_mock.async_get_time = AsyncMock() host_mock.check_new_firmware = AsyncMock(return_value=False) host_mock.subscribe = AsyncMock() host_mock.unsubscribe = AsyncMock(return_value=True) @@ -77,7 +76,21 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.get_stream_source = AsyncMock() host_mock.get_snapshot = AsyncMock() host_mock.get_encoding = AsyncMock(return_value="h264") + host_mock.pull_point_request = AsyncMock() + host_mock.set_audio = AsyncMock() + host_mock.set_email = AsyncMock() + host_mock.set_siren = AsyncMock() host_mock.ONVIF_event_callback = AsyncMock() + host_mock.set_whiteled = AsyncMock() + host_mock.set_state_light = AsyncMock() + host_mock.renew = AsyncMock() + host_mock.get_vod_source = AsyncMock() + host_mock.request_vod_files = AsyncMock() + host_mock.expire_session = AsyncMock() + host_mock.set_volume = AsyncMock() + host_mock.set_hub_audio = AsyncMock() + host_mock.play_quick_reply = AsyncMock() + host_mock.update_firmware = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -103,7 +116,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.supported.return_value = True host_mock.item_number.return_value = TEST_ITEM_NUMBER host_mock.camera_model.return_value = TEST_CAM_MODEL - host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_name.return_value = TEST_CAM_NAME host_mock.camera_hardware_version.return_value = "IPC_00001" host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.camera_sw_version_update_required.return_value = False @@ -114,9 +127,10 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection = False - host_mock.wifi_signal = None + host_mock.wifi_connection.return_value = False + host_mock.wifi_signal.return_value = -45 host_mock.whiteled_mode_list.return_value = [] + host_mock.post_recording_time_list.return_value = [] host_mock.zoom_range.return_value = { "zoom": {"pos": {"min": 0, "max": 100}}, "focus": {"pos": {"min": 0, "max": 100}}, @@ -141,6 +155,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.recording_packing_time = "60 Minutes" # Baichuan + host_mock.baichuan = MagicMock() host_mock.baichuan_only = False # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT @@ -149,6 +164,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.unsubscribe_events = AsyncMock() host_mock.baichuan.check_subscribe_events = AsyncMock() host_mock.baichuan.get_privacy_mode = AsyncMock() + host_mock.baichuan.set_privacy_mode = AsyncMock() + host_mock.baichuan.set_scene = AsyncMock() host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" @@ -159,44 +176,27 @@ def _init_host_mock(host_mock: MagicMock) -> None: 0: {"chnID": 0, "aitype": 34615}, "Host": {"pushAlarm": 7}, } + host_mock.baichuan.set_smart_ai = AsyncMock() host_mock.baichuan.smart_location_list.return_value = [0] host_mock.baichuan.smart_ai_type_list.return_value = ["people"] host_mock.baichuan.smart_ai_index.return_value = 1 host_mock.baichuan.smart_ai_name.return_value = "zone1" -@pytest.fixture(scope="module") -def reolink_connect_class() -> Generator[MagicMock]: +@pytest.fixture +def reolink_host_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" - with ( - patch( - "homeassistant.components.reolink.host.Host", autospec=True - ) as host_mock_class, - ): - host_mock = host_mock_class.return_value - host_mock.baichuan = create_autospec(Baichuan) - _init_host_mock(host_mock) + with patch( + "homeassistant.components.reolink.host.Host", autospec=False + ) as host_mock_class: + _init_host_mock(host_mock_class.return_value) yield host_mock_class @pytest.fixture -def reolink_connect( - reolink_connect_class: MagicMock, -) -> Generator[MagicMock]: - """Mock reolink connection.""" - return reolink_connect_class.return_value - - -@pytest.fixture -def reolink_host() -> Generator[MagicMock]: +def reolink_host(reolink_host_class: MagicMock) -> Generator[MagicMock]: """Mock reolink Host class.""" - with patch( - "homeassistant.components.reolink.host.Host", autospec=False - ) as host_mock_class: - host_mock = host_mock_class.return_value - host_mock.baichuan = MagicMock() - _init_host_mock(host_mock) - yield host_mock + return reolink_host_class.return_value @pytest.fixture @@ -231,29 +231,6 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry -@pytest.fixture -def test_chime(reolink_connect: MagicMock) -> None: - """Mock a reolink chime.""" - TEST_CHIME = Chime( - host=reolink_connect, - dev_id=12345678, - channel=0, - ) - TEST_CHIME.name = "Test chime" - TEST_CHIME.volume = 3 - TEST_CHIME.connect_state = 2 - TEST_CHIME.led_state = True - TEST_CHIME.event_info = { - "md": {"switch": 0, "musicId": 0}, - "people": {"switch": 0, "musicId": 1}, - "visitor": {"switch": 1, "musicId": 2}, - } - - reolink_connect.chime_list = [TEST_CHIME] - reolink_connect.chime.return_value = TEST_CHIME - return TEST_CHIME - - @pytest.fixture def reolink_chime(reolink_host: MagicMock) -> None: """Mock a reolink chime.""" @@ -271,6 +248,8 @@ def reolink_chime(reolink_host: MagicMock) -> None: "people": {"switch": 0, "musicId": 1}, "visitor": {"switch": 1, "musicId": 2}, } + TEST_CHIME.remove = AsyncMock() + TEST_CHIME.set_option = AsyncMock() reolink_host.chime_list = [TEST_CHIME] reolink_host.chime.return_value = TEST_CHIME diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index a6d7f14a149..ca35d7eb70f 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -28,6 +28,7 @@ 'HTTPS': True, 'IPC cams': dict({ '0': dict({ + 'WiFi signal': -45, 'encoding main': 'h264', 'firmware version': 'v1.1.0.0.0.0000', 'hardware version': 'IPC_00001', @@ -37,8 +38,8 @@ 'ONVIF enabled': True, 'RTMP enabled': True, 'RTSP enabled': True, - 'WiFi connection': False, - 'WiFi signal': None, + 'WiFi connection': True, + 'WiFi signal': -45, 'abilities': dict({ 'abilityChn': list([ dict({ @@ -76,6 +77,10 @@ '0': 1, 'null': 1, }), + '594': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, @@ -85,8 +90,8 @@ 'null': 5, }), 'GetAiCfg': dict({ - '0': 4, - 'null': 4, + '0': 2, + 'null': 2, }), 'GetAudioAlarm': dict({ '0': 1, @@ -172,10 +177,6 @@ '0': 2, 'null': 2, }), - 'GetPtzTraceSection': dict({ - '0': 2, - 'null': 2, - }), 'GetPush': dict({ '0': 1, 'null': 2, @@ -191,8 +192,8 @@ 'null': 1, }), 'GetWhiteLed': dict({ - '0': 3, - 'null': 3, + '0': 2, + 'null': 2, }), 'GetZoomFocus': dict({ '0': 2, diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index e6275a2108e..4bbe222fad6 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from .conftest import TEST_DUO_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL, TEST_HOST_MODEL from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -31,7 +31,7 @@ async def test_motion_sensor( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion_lens_0" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion_lens_0" assert hass.states.get(entity_id).state == STATE_ON reolink_host.motion_detected.return_value = False @@ -66,7 +66,7 @@ async def test_smart_ai_sensor( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_crossline_zone1_person" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_crossline_zone1_person" assert hass.states.get(entity_id).state == STATE_ON reolink_host.baichuan.smart_ai_state.return_value = False @@ -106,7 +106,7 @@ async def test_tcp_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion" assert hass.states.get(entity_id).state == STATE_ON # simulate a TCP push callback diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index ee51d0f0b99..1e773491938 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -29,7 +29,7 @@ async def test_button( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + entity_id = f"{Platform.BUTTON}.{TEST_CAM_NAME}_ptz_up" await hass.services.async_call( BUTTON_DOMAIN, @@ -60,7 +60,7 @@ async def test_ptz_move_service( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + entity_id = f"{Platform.BUTTON}.{TEST_CAM_NAME}_ptz_up" await hass.services.async_call( DOMAIN, diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 4ab43de225f..99236526070 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -15,7 +15,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -33,7 +33,7 @@ async def test_camera( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_fluent" + entity_id = f"{Platform.CAMERA}.{TEST_CAM_NAME}_fluent" assert hass.states.get(entity_id).state == CameraState.IDLE # check getting a image from the camera @@ -63,5 +63,5 @@ async def test_camera_no_stream_source( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_snapshots_fluent_lens_0" + entity_id = f"{Platform.CAMERA}.{TEST_CAM_NAME}_snapshots_fluent_lens_0" assert hass.states.get(entity_id).state == CameraState.IDLE diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4b116929ac8..0a837a97b20 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -58,7 +58,7 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures("reolink_connect") +pytestmark = pytest.mark.usefixtures("reolink_host") async def test_config_flow_manual_success( @@ -101,11 +101,11 @@ async def test_config_flow_manual_success( async def test_config_flow_privacy_success( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow when privacy mode is turned on.""" - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.get_host_data.side_effect = LoginPrivacyModeError("Test error") + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.get_host_data.side_effect = LoginPrivacyModeError("Test error") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -128,13 +128,13 @@ async def test_config_flow_privacy_success( assert result["step_id"] == "privacy" assert result["errors"] is None - assert reolink_connect.baichuan.set_privacy_mode.call_count == 0 - reolink_connect.get_host_data.reset_mock(side_effect=True) + assert reolink_host.baichuan.set_privacy_mode.call_count == 0 + reolink_host.get_host_data.reset_mock(side_effect=True) with patch("homeassistant.components.reolink.config_flow.API_STARTUP_TIME", new=0): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert reolink_connect.baichuan.set_privacy_mode.call_count == 1 + assert reolink_host.baichuan.set_privacy_mode.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_NVR_NAME @@ -153,14 +153,12 @@ async def test_config_flow_privacy_success( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan.privacy_mode.return_value = False - async def test_config_flow_baichuan_only( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user for baichuan only device.""" - reolink_connect.baichuan_only = True + reolink_host.baichuan_only = True result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -196,11 +194,9 @@ async def test_config_flow_baichuan_only( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan_only = False - async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -211,10 +207,10 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {} - reolink_connect.is_admin = False - reolink_connect.user_level = "guest" - reolink_connect.unsubscribe.side_effect = ReolinkError("Test error") - reolink_connect.logout.side_effect = ReolinkError("Test error") + reolink_host.is_admin = False + reolink_host.user_level = "guest" + reolink_host.unsubscribe.side_effect = ReolinkError("Test error") + reolink_host.logout.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -228,9 +224,9 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_USERNAME: "not_admin"} - reolink_connect.is_admin = True - reolink_connect.user_level = "admin" - reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + reolink_host.is_admin = True + reolink_host.user_level = "admin" + reolink_host.get_host_data.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -244,7 +240,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "cannot_connect"} - reolink_connect.get_host_data.side_effect = ReolinkWebhookException("Test error") + reolink_host.get_host_data.side_effect = ReolinkWebhookException("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -258,7 +254,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "webhook_exception"} - reolink_connect.get_host_data.side_effect = json.JSONDecodeError( + reolink_host.get_host_data.side_effect = json.JSONDecodeError( "test_error", "test", 1 ) result = await hass.config_entries.flow.async_configure( @@ -274,7 +270,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "unknown"} - reolink_connect.get_host_data.side_effect = CredentialsInvalidError("Test error") + reolink_host.get_host_data.side_effect = CredentialsInvalidError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -288,7 +284,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} - reolink_connect.get_host_data.side_effect = LoginFirmwareError("Test error") + reolink_host.get_host_data.side_effect = LoginFirmwareError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -302,7 +298,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "update_needed"} - reolink_connect.valid_password.return_value = False + reolink_host.valid_password.return_value = False result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -316,8 +312,8 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "password_incompatible"} - reolink_connect.valid_password.return_value = True - reolink_connect.get_host_data.side_effect = ApiError("Test error") + reolink_host.valid_password.return_value = True + reolink_host.get_host_data.side_effect = ApiError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -331,7 +327,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "api_error"} - reolink_connect.get_host_data.reset_mock(side_effect=True) + reolink_host.get_host_data.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -360,9 +356,6 @@ async def test_config_flow_errors( CONF_PROTOCOL: DEFAULT_PROTOCOL, } - reolink_connect.unsubscribe.reset_mock(side_effect=True) - reolink_connect.logout.reset_mock(side_effect=True) - async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" @@ -450,7 +443,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: async def test_reauth_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( @@ -475,7 +468,7 @@ async def test_reauth_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reauth_flow(hass) @@ -497,8 +490,6 @@ async def test_reauth_abort_unique_id_mismatch( assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - reolink_connect.mac_address = TEST_MAC - async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" @@ -544,8 +535,8 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No async def test_dhcp_ip_update_aborted_if_wrong_mac( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery does not update the IP if the mac address does not match.""" config_entry = MockConfigEntry( @@ -572,7 +563,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -583,7 +574,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( macaddress=DHCP_FORMATTED_MAC, ) - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -602,9 +593,9 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in [TEST_HOST, TEST_HOST2] get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -616,10 +607,6 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( # Check that IP was not updated assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - reolink_connect.mac_address = TEST_MAC - @pytest.mark.parametrize( ("attr", "value", "expected", "host_call_list"), @@ -641,8 +628,8 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( async def test_dhcp_ip_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, attr: str, value: Any, expected: str, @@ -673,7 +660,7 @@ async def test_dhcp_ip_update( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -685,8 +672,7 @@ async def test_dhcp_ip_update( ) if attr is not None: - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -705,9 +691,9 @@ async def test_dhcp_ip_update( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in host_call_list get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -718,17 +704,12 @@ async def test_dhcp_ip_update( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == expected - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - if attr is not None: - setattr(reolink_connect, attr, original) - async def test_dhcp_ip_update_ingnored_if_still_connected( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery is ignored when the camera is still properly connected to HA.""" config_entry = MockConfigEntry( @@ -776,9 +757,9 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] == TEST_HOST get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -789,9 +770,6 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reconfiguration flow.""" @@ -840,7 +818,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non async def test_reconfig_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reconfiguration flow aborts if the unique id does not match.""" config_entry = MockConfigEntry( @@ -865,7 +843,7 @@ async def test_reconfig_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reconfigure_flow(hass) @@ -887,5 +865,3 @@ async def test_reconfig_abort_unique_id_mismatch( assert config_entry.data[CONF_HOST] == TEST_HOST assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - - reolink_connect.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index b347bae9ec0..3e8ab4d0b2b 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -21,6 +21,8 @@ async def test_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test Reolink diagnostics.""" + reolink_host.wifi_connection.return_value = True + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index f997a1ac08a..194d038a32a 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -28,7 +28,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError from homeassistant.util.aiohttp import MockRequest -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -39,11 +39,10 @@ async def test_setup_with_tcp_push( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test successful setup of the integration with TCP push callbacks.""" - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.events_active = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,54 +53,46 @@ async def test_setup_with_tcp_push( await hass.async_block_till_done() # ONVIF push subscription not called - assert not reolink_connect.subscribe.called - - reolink_connect.baichuan.events_active = False - reolink_connect.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + assert not reolink_host.subscribe.called async def test_unloading_with_tcp_push( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test successful unloading of the integration with TCP push callbacks.""" - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.events_active = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.baichuan.unsubscribe_events.side_effect = ReolinkError("Test error") + reolink_host.baichuan.unsubscribe_events.side_effect = ReolinkError("Test error") # Unload the config entry assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - reolink_connect.baichuan.events_active = False - reolink_connect.baichuan.subscribe_events.side_effect = ReolinkError("Test error") - reolink_connect.baichuan.unsubscribe_events.reset_mock(side_effect=True) - async def test_webhook_callback( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test webhook callback with motion sensor.""" - reolink_connect.motion_detected.return_value = False + reolink_host.motion_detected.return_value = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion" webhook_id = config_entry.runtime_data.host.webhook_id unique_id = config_entry.runtime_data.host.unique_id @@ -115,9 +106,9 @@ async def test_webhook_callback( assert hass.states.get(entity_id).state == STATE_OFF # test webhook callback success all channels - reolink_connect.get_motion_state_all_ch.return_value = True - reolink_connect.motion_detected.return_value = True - reolink_connect.ONVIF_event_callback.return_value = None + reolink_host.get_motion_state_all_ch.return_value = True + reolink_host.motion_detected.return_value = True + reolink_host.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") await hass.async_block_till_done() signal_all.assert_called_once() @@ -129,7 +120,7 @@ async def test_webhook_callback( # test webhook callback all channels with failure to read motion_state signal_all.reset_mock() - reolink_connect.get_motion_state_all_ch.return_value = False + reolink_host.get_motion_state_all_ch.return_value = False await client.post(f"/api/webhook/{webhook_id}") await hass.async_block_till_done() signal_all.assert_not_called() @@ -137,8 +128,8 @@ async def test_webhook_callback( assert hass.states.get(entity_id).state == STATE_ON # test webhook callback success single channel - reolink_connect.motion_detected.return_value = False - reolink_connect.ONVIF_event_callback.return_value = [0] + reolink_host.motion_detected.return_value = False + reolink_host.ONVIF_event_callback.return_value = [0] await client.post(f"/api/webhook/{webhook_id}", data="test_data") await hass.async_block_till_done() signal_ch.assert_called_once() @@ -146,7 +137,7 @@ async def test_webhook_callback( # test webhook callback single channel with error in event callback signal_ch.reset_mock() - reolink_connect.ONVIF_event_callback.side_effect = Exception("Test error") + reolink_host.ONVIF_event_callback.side_effect = Exception("Test error") await client.post(f"/api/webhook/{webhook_id}", data="test_data") await hass.async_block_till_done() signal_ch.assert_not_called() @@ -171,45 +162,42 @@ async def test_webhook_callback( await async_handle_webhook(hass, webhook_id, request) signal_all.assert_not_called() - reolink_connect.ONVIF_event_callback.reset_mock(side_effect=True) - async def test_no_mac( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test setup of host with no mac.""" - original = reolink_connect.mac_address - reolink_connect.mac_address = None + original = reolink_host.mac_address + reolink_host.mac_address = None assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY - reolink_connect.mac_address = original + reolink_host.mac_address = original async def test_subscribe_error( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test error when subscribing to ONVIF does not block startup.""" - reolink_connect.subscribe.side_effect = ReolinkError("Test Error") - reolink_connect.subscribed.return_value = False + reolink_host.subscribe.side_effect = ReolinkError("Test Error") + reolink_host.subscribed.return_value = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.subscribe.reset_mock(side_effect=True) async def test_subscribe_unsuccesfull( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test that a unsuccessful ONVIF subscription does not block startup.""" - reolink_connect.subscribed.return_value = False + reolink_host.subscribed.return_value = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED @@ -218,7 +206,7 @@ async def test_subscribe_unsuccesfull( async def test_initial_ONVIF_not_supported( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test setup when initial ONVIF is not supported.""" @@ -228,7 +216,7 @@ async def test_initial_ONVIF_not_supported( return False return True - reolink_connect.supported = test_supported + reolink_host.supported = test_supported assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -238,7 +226,7 @@ async def test_initial_ONVIF_not_supported( async def test_ONVIF_not_supported( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test setup is not blocked when ONVIF API returns NotSupportedError.""" @@ -248,26 +236,23 @@ async def test_ONVIF_not_supported( return False return True - reolink_connect.supported = test_supported - reolink_connect.subscribed.return_value = False - reolink_connect.subscribe.side_effect = NotSupportedError("Test error") + reolink_host.supported = test_supported + reolink_host.subscribed.return_value = False + reolink_host.subscribe.side_effect = NotSupportedError("Test error") assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.subscribe.reset_mock(side_effect=True) - reolink_connect.subscribed.return_value = True - async def test_renew( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test renew of the ONVIF subscription.""" - reolink_connect.renewtimer.return_value = 1 + reolink_host.renewtimer.return_value = 1 assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -277,56 +262,51 @@ async def test_renew( async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.renew.assert_called() + reolink_host.renew.assert_called() - reolink_connect.renew.side_effect = SubscriptionError("Test error") + reolink_host.renew.side_effect = SubscriptionError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.subscribe.assert_called() + reolink_host.subscribe.assert_called() - reolink_connect.subscribe.reset_mock() - reolink_connect.subscribe.side_effect = SubscriptionError("Test error") + reolink_host.subscribe.reset_mock() + reolink_host.subscribe.side_effect = SubscriptionError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.subscribe.assert_called() - - reolink_connect.renew.reset_mock(side_effect=True) - reolink_connect.subscribe.reset_mock(side_effect=True) + reolink_host.subscribe.assert_called() async def test_long_poll_renew_fail( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test ONVIF long polling errors while renewing.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.subscribe.side_effect = NotSupportedError("Test error") + reolink_host.subscribe.side_effect = NotSupportedError("Test error") freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) async_fire_time_changed(hass) await hass.async_block_till_done() # ensure long polling continues - reolink_connect.pull_point_request.assert_called() - - reolink_connect.subscribe.reset_mock(side_effect=True) + reolink_host.pull_point_request.assert_called() async def test_register_webhook_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors while registering the webhook.""" with patch( @@ -343,7 +323,7 @@ async def test_long_poll_stop_when_push( hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test ONVIF long polling stops when ONVIF push comes in.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -357,7 +337,7 @@ async def test_long_poll_stop_when_push( # simulate ONVIF push callback client = await hass_client_no_auth() - reolink_connect.ONVIF_event_callback.return_value = None + reolink_host.ONVIF_event_callback.return_value = None webhook_id = config_entry.runtime_data.host.webhook_id await client.post(f"/api/webhook/{webhook_id}") @@ -365,31 +345,29 @@ async def test_long_poll_stop_when_push( async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.unsubscribe.assert_called_with(sub_type=SubType.long_poll) + reolink_host.unsubscribe.assert_called_with(sub_type=SubType.long_poll) async def test_long_poll_errors( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors during ONVIF long polling.""" - reolink_connect.pull_point_request.reset_mock() - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.pull_point_request.side_effect = ReolinkError("Test error") + reolink_host.pull_point_request.side_effect = ReolinkError("Test error") # start ONVIF long polling because ONVIF push did not came in freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.pull_point_request.assert_called_once() - reolink_connect.pull_point_request.side_effect = Exception("Test error") + reolink_host.pull_point_request.assert_called_once() + reolink_host.pull_point_request.side_effect = Exception("Test error") freezer.tick(timedelta(seconds=LONG_POLL_ERROR_COOLDOWN)) async_fire_time_changed(hass) @@ -399,21 +377,18 @@ async def test_long_poll_errors( async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.unsubscribe.assert_called_with(sub_type=SubType.long_poll) - - reolink_connect.pull_point_request.reset_mock(side_effect=True) + reolink_host.unsubscribe.assert_called_with(sub_type=SubType.long_poll) async def test_fast_polling_errors( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors during ONVIF fast polling.""" - reolink_connect.get_motion_state_all_ch.reset_mock() - reolink_connect.get_motion_state_all_ch.side_effect = ReolinkError("Test error") - reolink_connect.pull_point_request.side_effect = ReolinkError("Test error") + reolink_host.get_motion_state_all_ch.side_effect = ReolinkError("Test error") + reolink_host.pull_point_request.side_effect = ReolinkError("Test error") assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -429,17 +404,14 @@ async def test_fast_polling_errors( async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.get_motion_state_all_ch.call_count == 1 + assert reolink_host.get_motion_state_all_ch.call_count == 1 freezer.tick(timedelta(seconds=POLL_INTERVAL_NO_PUSH)) async_fire_time_changed(hass) await hass.async_block_till_done() # fast polling continues despite errors - assert reolink_connect.get_motion_state_all_ch.call_count == 2 - - reolink_connect.get_motion_state_all_ch.reset_mock(side_effect=True) - reolink_connect.pull_point_request.reset_mock(side_effect=True) + assert reolink_host.get_motion_state_all_ch.call_count == 2 async def test_diagnostics_event_connection( @@ -447,7 +419,7 @@ async def test_diagnostics_event_connection( hass_client: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test Reolink diagnostics event connection return values.""" @@ -468,7 +440,7 @@ async def test_diagnostics_event_connection( # simulate ONVIF push callback client = await hass_client_no_auth() - reolink_connect.ONVIF_event_callback.return_value = None + reolink_host.ONVIF_event_callback.return_value = None webhook_id = config_entry.runtime_data.host.webhook_id await client.post(f"/api/webhook/{webhook_id}") @@ -476,6 +448,6 @@ async def test_diagnostics_event_connection( assert diag["event connection"] == "ONVIF push" # set TCP push as active - reolink_connect.baichuan.events_active = True + reolink_host.baichuan.events_active = True diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag["event connection"] == "TCP push" diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index ed71314e961..662469ebc01 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from reolink_aio.api import Chime from reolink_aio.exceptions import ( CredentialsInvalidError, LoginPrivacyModeError, @@ -53,6 +52,7 @@ from .conftest import ( DEFAULT_PROTOCOL, TEST_BC_PORT, TEST_CAM_MODEL, + TEST_CAM_NAME, TEST_HOST, TEST_HOST_MODEL, TEST_MAC, @@ -181,26 +181,6 @@ async def test_credential_error_three( assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues -async def test_entry_reloading( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, -) -> None: - """Test the entry is reloaded correctly when settings change.""" - reolink_host.is_nvr = False - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 0 - assert config_entry.title == "test_reolink_name" - - hass.config_entries.async_update_entry(config_entry, title="New Name") - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 1 - assert config_entry.title == "New Name" - - @pytest.mark.parametrize( ("attr", "value", "expected_models"), [ @@ -270,22 +250,25 @@ async def test_removing_disconnected_cams( @pytest.mark.parametrize( - ("attr", "value", "expected_models"), + ("attr", "value", "expected_models", "expected_remove_call_count"), [ ( None, None, [TEST_HOST_MODEL, TEST_CAM_MODEL, CHIME_MODEL], + 1, ), ( "connect_state", -1, [TEST_HOST_MODEL, TEST_CAM_MODEL], + 0, ), ( "remove", -1, [TEST_HOST_MODEL, TEST_CAM_MODEL], + 1, ), ], ) @@ -294,12 +277,13 @@ async def test_removing_chime( hass_ws_client: WebSocketGenerator, config_entry: MockConfigEntry, reolink_host: MagicMock, - reolink_chime: Chime, + reolink_chime: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, attr: str | None, value: Any, expected_models: list[str], + expected_remove_call_count: int, ) -> None: """Test removing a chime.""" reolink_host.channels = [0] @@ -324,7 +308,7 @@ async def test_removing_chime( """Remove chime.""" reolink_chime.connect_state = -1 - reolink_chime.remove = test_remove_chime + reolink_chime.remove = AsyncMock(side_effect=test_remove_chime) elif attr is not None: setattr(reolink_chime, attr, value) @@ -334,6 +318,7 @@ async def test_removing_chime( if device.model == CHIME_MODEL: response = await client.remove_device(device.id, config_entry.entry_id) assert response["success"] == expected_success + assert reolink_chime.remove.call_count == expected_remove_call_count device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id @@ -1050,7 +1035,7 @@ async def test_privacy_mode_change_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # simulate a TCP push callback signaling a privacy mode change @@ -1122,7 +1107,7 @@ async def test_camera_wake_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON reolink_host.sleeping.return_value = False @@ -1156,11 +1141,11 @@ async def test_camera_wake_callback( async def test_baichaun_only( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test initializing a baichuan only device.""" - reolink_connect.baichuan_only = True + reolink_host.baichuan_only = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 948a7fce0fe..80a0a7abeab 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -17,57 +17,45 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("whiteled_brightness", "expected_brightness"), + [ + (100, 255), + (None, None), + ], +) async def test_light_state( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, + whiteled_brightness: int | None, + expected_brightness: int | None, ) -> None: """Test light entity state with floodlight.""" - reolink_connect.whiteled_state.return_value = True - reolink_connect.whiteled_brightness.return_value = 100 + reolink_host.whiteled_state.return_value = True + reolink_host.whiteled_brightness.return_value = whiteled_brightness with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes["brightness"] == 255 - - -async def test_light_brightness_none( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_connect: MagicMock, -) -> None: - """Test light entity with floodlight and brightness returning None.""" - reolink_connect.whiteled_state.return_value = True - reolink_connect.whiteled_brightness.return_value = None - - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" - - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes["brightness"] is None + assert state.attributes["brightness"] == expected_brightness async def test_light_turn_off( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test light turn off service.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): @@ -75,7 +63,7 @@ async def test_light_turn_off( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" await hass.services.async_call( LIGHT_DOMAIN, @@ -83,9 +71,9 @@ async def test_light_turn_off( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_whiteled.assert_called_with(0, state=False) + reolink_host.set_whiteled.assert_called_with(0, state=False) - reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + reolink_host.set_whiteled.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, @@ -94,13 +82,11 @@ async def test_light_turn_off( blocking=True, ) - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_light_turn_on( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test light turn on service.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): @@ -108,7 +94,7 @@ async def test_light_turn_on( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" await hass.services.async_call( LIGHT_DOMAIN, @@ -116,47 +102,51 @@ async def test_light_turn_on( {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, blocking=True, ) - reolink_connect.set_whiteled.assert_has_calls( + reolink_host.set_whiteled.assert_has_calls( [call(0, brightness=20), call(0, state=True)] ) - reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + +@pytest.mark.parametrize( + ("exception", "service_data"), + [ + (ReolinkError("Test error"), {}), + (ReolinkError("Test error"), {ATTR_BRIGHTNESS: 51}), + (InvalidParameterError("Test error"), {ATTR_BRIGHTNESS: 51}), + ], +) +async def test_light_turn_on_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_host: MagicMock, + exception: Exception, + service_data: dict, +) -> None: + """Test light turn on service error cases.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" + + reolink_host.set_whiteled.side_effect = exception with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, + {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) - reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, - blocking=True, - ) - - reolink_connect.set_whiteled.side_effect = InvalidParameterError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, - blocking=True, - ) - - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_host_light_state( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host light entity state with status led.""" - reolink_connect.state_light = True + reolink_host.state_light = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -172,7 +162,7 @@ async def test_host_light_state( async def test_host_light_turn_off( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host light turn off service.""" @@ -181,7 +171,7 @@ async def test_host_light_turn_off( return False return True - reolink_connect.supported = mock_supported + reolink_host.supported = mock_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -196,9 +186,9 @@ async def test_host_light_turn_off( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_state_light.assert_called_with(False) + reolink_host.set_state_light.assert_called_with(False) - reolink_connect.set_state_light.side_effect = ReolinkError("Test error") + reolink_host.set_state_light.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, @@ -207,13 +197,11 @@ async def test_host_light_turn_off( blocking=True, ) - reolink_connect.set_state_light.reset_mock(side_effect=True) - async def test_host_light_turn_on( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host light turn on service.""" @@ -222,7 +210,7 @@ async def test_host_light_turn_on( return False return True - reolink_connect.supported = mock_supported + reolink_host.supported = mock_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -237,9 +225,9 @@ async def test_host_light_turn_on( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_state_light.assert_called_with(True) + reolink_host.set_state_light.assert_called_with(True) - reolink_connect.set_state_light.side_effect = ReolinkError("Test error") + reolink_host.set_state_light.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 67ae78e5fa4..c8bc8fd9c70 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -34,10 +34,10 @@ from homeassistant.setup import async_setup_component from .conftest import ( TEST_BC_PORT, + TEST_CAM_NAME, TEST_HOST2, TEST_HOST_MODEL, TEST_MAC2, - TEST_NVR_NAME, TEST_NVR_NAME2, TEST_PASSWORD2, TEST_PORT, @@ -61,7 +61,6 @@ TEST_FILE_NAME = f"{TEST_START}00" TEST_FILE_NAME_MP4 = f"{TEST_START}00.mp4" TEST_STREAM = "main" TEST_CHANNEL = "0" -TEST_CAM_NAME = "Cam new name" TEST_MIME_TYPE = "application/x-mpegURL" TEST_MIME_TYPE_MP4 = "video/mp4" @@ -89,7 +88,7 @@ async def test_platform_loads_before_config_entry( async def test_resolve( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: @@ -99,7 +98,7 @@ async def test_resolve( caplog.set_level(logging.DEBUG) file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -107,14 +106,14 @@ async def test_resolve( assert play_media.mime_type == TEST_MIME_TYPE_MP4 file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME_MP4}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None ) assert play_media.mime_type == TEST_MIME_TYPE_MP4 - reolink_connect.is_nvr = False + reolink_host.is_nvr = False play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -122,7 +121,7 @@ async def test_resolve( assert play_media.mime_type == TEST_MIME_TYPE_MP4 file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -132,16 +131,16 @@ async def test_resolve( async def test_browsing( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test browsing the Reolink three.""" entry_id = config_entry.entry_id - reolink_connect.supported.return_value = 1 - reolink_connect.model = "Reolink TrackMix PoE" - reolink_connect.is_nvr = False + reolink_host.supported.return_value = 1 + reolink_host.model = "Reolink TrackMix PoE" + reolink_host.is_nvr = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True @@ -172,7 +171,7 @@ async def test_browsing( browse_res_AT_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_sub" browse_res_AT_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_main" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0" + assert browse.title == f"{TEST_CAM_NAME} lens 0" assert browse.identifier == browse_resolution_id assert browse.children[0].identifier == browse_res_sub_id assert browse.children[1].identifier == browse_res_main_id @@ -184,23 +183,23 @@ async def test_browsing( mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH mock_status.days = (TEST_DAY, TEST_DAY2) - reolink_connect.request_vod_files.return_value = ([mock_status], []) + reolink_host.request_vod_files.return_value = ([mock_status], []) browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Low res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_sub_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto low res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Telephoto low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_main_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto high res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Telephoto high res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" @@ -210,7 +209,7 @@ async def test_browsing( browse_day_0_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" browse_day_1_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 High res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 High res." assert browse.identifier == browse_days_id assert browse.children[0].identifier == browse_day_0_id assert browse.children[1].identifier == browse_day_1_id @@ -223,7 +222,7 @@ async def test_browsing( mock_vod_file.duration = timedelta(minutes=5) mock_vod_file.file_name = TEST_FILE_NAME mock_vod_file.triggers = VOD_trigger.PERSON - reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file]) + reolink_host.request_vod_files.return_value = ([mock_status], [mock_vod_file]) browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") @@ -232,11 +231,11 @@ async def test_browsing( assert browse.domain == DOMAIN assert ( browse.title - == f"{TEST_NVR_NAME} lens 0 High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" + == f"{TEST_CAM_NAME} lens 0 High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id - reolink_connect.request_vod_files.assert_called_with( + reolink_host.request_vod_files.assert_called_with( int(TEST_CHANNEL), TEST_START_TIME, TEST_END_TIME, @@ -245,10 +244,10 @@ async def test_browsing( trigger=None, ) - reolink_connect.model = TEST_HOST_MODEL + reolink_host.model = TEST_HOST_MODEL # browse event trigger person on a NVR - reolink_connect.is_nvr = True + reolink_host.is_nvr = True browse_event_person_id = f"EVE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}|{VOD_trigger.PERSON.name}" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") @@ -261,11 +260,11 @@ async def test_browsing( assert browse.domain == DOMAIN assert ( browse.title - == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" + == f"{TEST_CAM_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id - reolink_connect.request_vod_files.assert_called_with( + reolink_host.request_vod_files.assert_called_with( int(TEST_CHANNEL), TEST_START_TIME, TEST_END_TIME, @@ -274,16 +273,15 @@ async def test_browsing( trigger=VOD_trigger.PERSON, ) - reolink_connect.is_nvr = False - async def test_browsing_h265_encoding( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera with h265 stream encoding.""" entry_id = config_entry.entry_id + reolink_host.is_nvr = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True @@ -295,10 +293,10 @@ async def test_browsing_h265_encoding( mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH mock_status.days = (TEST_DAY, TEST_DAY2) - reolink_connect.request_vod_files.return_value = ([mock_status], []) - reolink_connect.time.return_value = None - reolink_connect.get_encoding.return_value = "h265" - reolink_connect.supported.return_value = False + reolink_host.request_vod_files.return_value = ([mock_status], []) + reolink_host.time.return_value = None + reolink_host.get_encoding.return_value = "h265" + reolink_host.supported.return_value = False browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") @@ -307,7 +305,7 @@ async def test_browsing_h265_encoding( browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME}" + assert browse.title == f"{TEST_CAM_NAME}" assert browse.identifier == browse_resolution_id assert browse.children[0].identifier == browse_res_sub_id assert browse.children[1].identifier == browse_res_main_id @@ -322,7 +320,7 @@ async def test_browsing_h265_encoding( f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} Low res." + assert browse.title == f"{TEST_CAM_NAME} Low res." assert browse.identifier == browse_days_id assert browse.children[0].identifier == browse_day_0_id assert browse.children[1].identifier == browse_day_1_id @@ -330,7 +328,7 @@ async def test_browsing_h265_encoding( async def test_browsing_rec_playback_unsupported( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera which does not support playback of recordings.""" @@ -341,7 +339,7 @@ async def test_browsing_rec_playback_unsupported( return False return True - reolink_connect.supported = test_supported + reolink_host.supported = test_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -355,12 +353,10 @@ async def test_browsing_rec_playback_unsupported( assert browse.identifier is None assert browse.children == [] - reolink_connect.supported = lambda ch, key: True # Reset supported function - async def test_browsing_errors( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera errors.""" @@ -377,7 +373,7 @@ async def test_browsing_errors( async def test_browsing_not_loaded( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera integration which is not loaded.""" @@ -385,7 +381,7 @@ async def test_browsing_not_loaded( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + reolink_host.get_host_data.side_effect = ReolinkError("Test error") config_entry2 = MockConfigEntry( domain=DOMAIN, unique_id=format_mac(TEST_MAC2), @@ -413,5 +409,3 @@ async def test_browsing_not_loaded( assert browse.title == "Reolink" assert browse.identifier is None assert len(browse.children) == 1 - - reolink_connect.get_host_data.side_effect = None diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index dd70376d658..853edeefa5a 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -1,6 +1,6 @@ """Test the Reolink number platform.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from reolink_aio.api import Chime @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -24,17 +24,17 @@ from tests.common import MockConfigEntry async def test_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with volume.""" - reolink_connect.volume.return_value = 80 + reolink_host.volume.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_volume" assert hass.states.get(entity_id).state == "80" @@ -44,9 +44,9 @@ async def test_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, blocking=True, ) - reolink_connect.set_volume.assert_called_with(0, volume=50) + reolink_host.set_volume.assert_called_with(0, volume=50) - reolink_connect.set_volume.side_effect = ReolinkError("Test error") + reolink_host.set_volume.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -55,7 +55,7 @@ async def test_number( blocking=True, ) - reolink_connect.set_volume.side_effect = InvalidParameterError("Test error") + reolink_host.set_volume.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -64,24 +64,22 @@ async def test_number( blocking=True, ) - reolink_connect.set_volume.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_smart_ai_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with smart ai sensitivity.""" - reolink_connect.baichuan.smart_ai_sensitivity.return_value = 80 + reolink_host.baichuan.smart_ai_sensitivity.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_AI_crossline_zone1_sensitivity" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_AI_crossline_zone1_sensitivity" assert hass.states.get(entity_id).state == "80" @@ -91,13 +89,11 @@ async def test_smart_ai_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, blocking=True, ) - reolink_connect.baichuan.set_smart_ai.assert_called_with( + reolink_host.baichuan.set_smart_ai.assert_called_with( 0, "crossline", 0, sensitivity=50 ) - reolink_connect.baichuan.set_smart_ai.side_effect = InvalidParameterError( - "Test error" - ) + reolink_host.baichuan.set_smart_ai.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -106,16 +102,14 @@ async def test_smart_ai_number( blocking=True, ) - reolink_connect.baichuan.set_smart_ai.reset_mock(side_effect=True) - async def test_host_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with volume.""" - reolink_connect.alarm_volume = 85 + reolink_host.alarm_volume = 85 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -132,9 +126,9 @@ async def test_host_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, blocking=True, ) - reolink_connect.set_hub_audio.assert_called_with(alarm_volume=45) + reolink_host.set_hub_audio.assert_called_with(alarm_volume=45) - reolink_connect.set_hub_audio.side_effect = ReolinkError("Test error") + reolink_host.set_hub_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -143,7 +137,7 @@ async def test_host_number( blocking=True, ) - reolink_connect.set_hub_audio.side_effect = InvalidParameterError("Test error") + reolink_host.set_hub_audio.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -156,11 +150,11 @@ async def test_host_number( async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, ) -> None: """Test number entity of a chime with chime volume.""" - test_chime.volume = 3 + reolink_chime.volume = 3 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -171,16 +165,15 @@ async def test_chime_number( assert hass.states.get(entity_id).state == "3" - test_chime.set_option = AsyncMock() await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 2}, blocking=True, ) - test_chime.set_option.assert_called_with(volume=2) + reolink_chime.set_option.assert_called_with(volume=2) - test_chime.set_option.side_effect = ReolinkError("Test error") + reolink_chime.set_option.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -189,7 +182,7 @@ async def test_chime_number( blocking=True, ) - test_chime.set_option.side_effect = InvalidParameterError("Test error") + reolink_chime.set_option.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -197,5 +190,3 @@ async def test_chime_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 1}, blocking=True, ) - - test_chime.set_option.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 32bc5e4435e..5dcce747518 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -29,7 +29,7 @@ async def test_floodlight_mode_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select entity with floodlight_mode.""" @@ -38,7 +38,7 @@ async def test_floodlight_mode_select( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_floodlight_mode" + entity_id = f"{Platform.SELECT}.{TEST_CAM_NAME}_floodlight_mode" assert hass.states.get(entity_id).state == "auto" await hass.services.async_call( @@ -47,9 +47,9 @@ async def test_floodlight_mode_select( {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - reolink_connect.set_whiteled.assert_called_once() + reolink_host.set_whiteled.assert_called_once() - reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + reolink_host.set_whiteled.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -58,7 +58,7 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.set_whiteled.side_effect = InvalidParameterError("Test error") + reolink_host.set_whiteled.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -67,30 +67,28 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.whiteled_mode.return_value = -99 # invalid value + reolink_host.whiteled_mode.return_value = -99 # invalid value freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_play_quick_reply_message( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select play_quick_reply_message entity.""" - reolink_connect.quick_reply_dict.return_value = {0: "off", 1: "test message"} + reolink_host.quick_reply_dict.return_value = {0: "off", 1: "test message"} with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_play_quick_reply_message" + entity_id = f"{Platform.SELECT}.{TEST_CAM_NAME}_play_quick_reply_message" assert hass.states.get(entity_id).state == STATE_UNKNOWN await hass.services.async_call( @@ -99,16 +97,14 @@ async def test_play_quick_reply_message( {ATTR_ENTITY_ID: entity_id, "option": "test message"}, blocking=True, ) - reolink_connect.play_quick_reply.assert_called_once() - - reolink_connect.quick_reply_dict = MagicMock() + reolink_host.play_quick_reply.assert_called_once() async def test_host_scene_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host select entity with scene mode.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): @@ -125,9 +121,9 @@ async def test_host_scene_select( {ATTR_ENTITY_ID: entity_id, "option": "home"}, blocking=True, ) - reolink_connect.baichuan.set_scene.assert_called_once() + reolink_host.baichuan.set_scene.assert_called_once() - reolink_connect.baichuan.set_scene.side_effect = ReolinkError("Test error") + reolink_host.baichuan.set_scene.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -136,7 +132,7 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.set_scene.side_effect = InvalidParameterError("Test error") + reolink_host.baichuan.set_scene.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -145,23 +141,20 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.active_scene = "Invalid value" + reolink_host.baichuan.active_scene = "Invalid value" freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.baichuan.set_scene.reset_mock(side_effect=True) - reolink_connect.baichuan.active_scene = "off" - async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime select entity.""" @@ -175,16 +168,16 @@ async def test_chime_select( assert hass.states.get(entity_id).state == "pianokey" # Test selecting chime ringtone option - test_chime.set_tone = AsyncMock() + reolink_chime.set_tone = AsyncMock() await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - test_chime.set_tone.assert_called_once() + reolink_chime.set_tone.assert_called_once() - test_chime.set_tone.side_effect = ReolinkError("Test error") + reolink_chime.set_tone.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -193,7 +186,7 @@ async def test_chime_select( blocking=True, ) - test_chime.set_tone.side_effect = InvalidParameterError("Test error") + reolink_chime.set_tone.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -203,11 +196,9 @@ async def test_chime_select( ) # Test unavailable - test_chime.event_info = {} + reolink_chime.event_info = {} freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - - test_chime.set_tone.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index df164634355..9049d5906fc 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -17,25 +17,25 @@ from tests.common import MockConfigEntry async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test sensor entities.""" - reolink_connect.ptz_pan_position.return_value = 1200 - reolink_connect.wifi_connection = True - reolink_connect.wifi_signal = 3 - reolink_connect.hdd_list = [0] - reolink_connect.hdd_storage.return_value = 95 + reolink_host.ptz_pan_position.return_value = 1200 + reolink_host.wifi_connection.return_value = True + reolink_host.wifi_signal.return_value = -55 + reolink_host.hdd_list = [0] + reolink_host.hdd_storage.return_value = 95 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_ptz_pan_position" + entity_id = f"{Platform.SENSOR}.{TEST_CAM_NAME}_ptz_pan_position" assert hass.states.get(entity_id).state == "1200" - entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_wi_fi_signal" - assert hass.states.get(entity_id).state == "3" + entity_id = f"{Platform.SENSOR}.{TEST_CAM_NAME}_wi_fi_signal" + assert hass.states.get(entity_id).state == "-55" entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_sd_0_storage" assert hass.states.get(entity_id).state == "95" @@ -45,13 +45,13 @@ async def test_sensors( async def test_hdd_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test hdd sensor entity.""" - reolink_connect.hdd_list = [0] - reolink_connect.hdd_type.return_value = "HDD" - reolink_connect.hdd_storage.return_value = 85 - reolink_connect.hdd_available.return_value = False + reolink_host.hdd_list = [0] + reolink_host.hdd_type.return_value = "HDD" + reolink_host.hdd_storage.return_value = 85 + reolink_host.hdd_available.return_value = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/reolink/test_services.py b/tests/components/reolink/test_services.py index 6ae9a2d9729..38819bbd51d 100644 --- a/tests/components/reolink/test_services.py +++ b/tests/components/reolink/test_services.py @@ -20,8 +20,8 @@ from tests.common import MockConfigEntry async def test_play_chime_service_entity( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime play service.""" @@ -37,14 +37,14 @@ async def test_play_chime_service_entity( device_id = entity.device_id # Test chime play service with device - test_chime.play = AsyncMock() + reolink_chime.play = AsyncMock() await hass.services.async_call( DOMAIN, "play_chime", {ATTR_DEVICE_ID: [device_id], ATTR_RINGTONE: "attraction"}, blocking=True, ) - test_chime.play.assert_called_once() + reolink_chime.play.assert_called_once() # Test errors with pytest.raises(ServiceValidationError): @@ -55,7 +55,7 @@ async def test_play_chime_service_entity( blocking=True, ) - test_chime.play = AsyncMock(side_effect=ReolinkError("Test error")) + reolink_chime.play = AsyncMock(side_effect=ReolinkError("Test error")) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -64,7 +64,7 @@ async def test_play_chime_service_entity( blocking=True, ) - test_chime.play = AsyncMock(side_effect=InvalidParameterError("Test error")) + reolink_chime.play = AsyncMock(side_effect=InvalidParameterError("Test error")) with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, @@ -73,7 +73,7 @@ async def test_play_chime_service_entity( blocking=True, ) - reolink_connect.chime.return_value = None + reolink_host.chime.return_value = None with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, @@ -86,8 +86,8 @@ async def test_play_chime_service_entity( async def test_play_chime_service_unloaded( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime play service when config entry is unloaded.""" diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index f6ba8e0ea77..47e0e47e57f 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME from tests.common import MockConfigEntry @@ -30,7 +30,7 @@ from tests.common import MockConfigEntry async def test_siren( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test siren entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SIREN]): @@ -38,7 +38,7 @@ async def test_siren( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" assert hass.states.get(entity_id).state == STATE_UNKNOWN # test siren turn on @@ -48,8 +48,8 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_volume.assert_not_called() - reolink_connect.set_siren.assert_called_with(0, True, None) + reolink_host.set_volume.assert_not_called() + reolink_host.set_siren.assert_called_with(0, True, None) await hass.services.async_call( SIREN_DOMAIN, @@ -57,8 +57,8 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id, ATTR_VOLUME_LEVEL: 0.85, ATTR_DURATION: 2}, blocking=True, ) - reolink_connect.set_volume.assert_called_with(0, volume=85) - reolink_connect.set_siren.assert_called_with(0, True, 2) + reolink_host.set_volume.assert_called_with(0, 85) + reolink_host.set_siren.assert_called_with(0, True, 2) # test siren turn off await hass.services.async_call( @@ -67,7 +67,7 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_siren.assert_called_with(0, False, None) + reolink_host.set_siren.assert_called_with(0, False, None) @pytest.mark.parametrize("attr", ["set_volume", "set_siren"]) @@ -87,7 +87,7 @@ async def test_siren( async def test_siren_turn_on_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, attr: str, value: Any, expected: Any, @@ -98,10 +98,10 @@ async def test_siren_turn_on_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + original = getattr(reolink_host, attr) + setattr(reolink_host, attr, value) with pytest.raises(expected): await hass.services.async_call( SIREN_DOMAIN, @@ -110,13 +110,13 @@ async def test_siren_turn_on_errors( blocking=True, ) - setattr(reolink_connect, attr, original) + setattr(reolink_host, attr, original) async def test_siren_turn_off_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors when calling siren turn off service.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SIREN]): @@ -124,9 +124,9 @@ async def test_siren_turn_off_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" - reolink_connect.set_siren.side_effect = ReolinkError("Test error") + reolink_host.set_siren.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SIREN_DOMAIN, @@ -134,5 +134,3 @@ async def test_siren_turn_off_errors( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - reolink_connect.set_siren.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 2b2c33f0e8f..c8a38f19d5c 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -33,11 +33,10 @@ async def test_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.audio_record.return_value = True + reolink_host.audio_record.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -47,7 +46,7 @@ async def test_switch( entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.audio_record.return_value = False + reolink_host.audio_record.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -61,9 +60,9 @@ async def test_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_audio.assert_called_with(0, True) + reolink_host.set_audio.assert_called_with(0, True) - reolink_connect.set_audio.side_effect = ReolinkError("Test error") + reolink_host.set_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -73,16 +72,16 @@ async def test_switch( ) # test switch turn off - reolink_connect.set_audio.reset_mock(side_effect=True) + reolink_host.set_audio.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_audio.assert_called_with(0, False) + reolink_host.set_audio.assert_called_with(0, False) - reolink_connect.set_audio.side_effect = ReolinkError("Test error") + reolink_host.set_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -91,29 +90,26 @@ async def test_switch( blocking=True, ) - reolink_connect.set_audio.reset_mock(side_effect=True) + reolink_host.set_audio.reset_mock(side_effect=True) - reolink_connect.camera_online.return_value = False + reolink_host.camera_online.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - reolink_connect.camera_online.return_value = True - async def test_host_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.email_enabled.return_value = True - reolink_connect.is_hub = False - reolink_connect.supported.return_value = True + reolink_host.email_enabled.return_value = True + reolink_host.is_hub = False + reolink_host.supported.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -123,7 +119,7 @@ async def test_host_switch( entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_email_on_event" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.email_enabled.return_value = False + reolink_host.email_enabled.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -137,9 +133,9 @@ async def test_host_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_email.assert_called_with(None, True) + reolink_host.set_email.assert_called_with(None, True) - reolink_connect.set_email.side_effect = ReolinkError("Test error") + reolink_host.set_email.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -149,16 +145,16 @@ async def test_host_switch( ) # test switch turn off - reolink_connect.set_email.reset_mock(side_effect=True) + reolink_host.set_email.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_email.assert_called_with(None, False) + reolink_host.set_email.assert_called_with(None, False) - reolink_connect.set_email.side_effect = ReolinkError("Test error") + reolink_host.set_email.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -167,15 +163,13 @@ async def test_host_switch( blocking=True, ) - reolink_connect.set_email.reset_mock(side_effect=True) - async def test_chime_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, ) -> None: """Test host switch entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): @@ -186,7 +180,7 @@ async def test_chime_switch( entity_id = f"{Platform.SWITCH}.test_chime_led" assert hass.states.get(entity_id).state == STATE_ON - test_chime.led_state = False + reolink_chime.led_state = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -194,16 +188,16 @@ async def test_chime_switch( assert hass.states.get(entity_id).state == STATE_OFF # test switch turn on - test_chime.set_option = AsyncMock() + reolink_chime.set_option = AsyncMock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - test_chime.set_option.assert_called_with(led=True) + reolink_chime.set_option.assert_called_with(led=True) - test_chime.set_option.side_effect = ReolinkError("Test error") + reolink_chime.set_option.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -213,16 +207,16 @@ async def test_chime_switch( ) # test switch turn off - test_chime.set_option.reset_mock(side_effect=True) + reolink_chime.set_option.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - test_chime.set_option.assert_called_with(led=False) + reolink_chime.set_option.assert_called_with(led=False) - test_chime.set_option.side_effect = ReolinkError("Test error") + reolink_chime.set_option.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -231,8 +225,6 @@ async def test_chime_switch( blocking=True, ) - test_chime.set_option.reset_mock(side_effect=True) - @pytest.mark.parametrize( ( @@ -265,7 +257,7 @@ async def test_chime_switch( async def test_cleanup_hub_switches( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, original_id: str, capability: str, @@ -279,9 +271,9 @@ async def test_cleanup_hub_switches( domain = Platform.SWITCH - reolink_connect.channels = [0] - reolink_connect.is_hub = True - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.is_hub = True + reolink_host.supported = mock_supported entity_registry.async_get_or_create( domain=domain, @@ -301,9 +293,6 @@ async def test_cleanup_hub_switches( assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None - reolink_connect.is_hub = False - reolink_connect.supported.return_value = True - @pytest.mark.parametrize( ( @@ -336,7 +325,7 @@ async def test_cleanup_hub_switches( async def test_hub_switches_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, original_id: str, @@ -351,9 +340,9 @@ async def test_hub_switches_repair_issue( domain = Platform.SWITCH - reolink_connect.channels = [0] - reolink_connect.is_hub = True - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.is_hub = True + reolink_host.supported = mock_supported entity_registry.async_get_or_create( domain=domain, @@ -373,6 +362,3 @@ async def test_hub_switches_repair_issue( assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) assert (DOMAIN, "hub_switch_deprecated") in issue_registry.issues - - reolink_connect.is_hub = False - reolink_connect.supported.return_value = True diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index d48362516b8..ce24734f9c1 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -30,12 +30,10 @@ TEST_RELEASE_NOTES = "bugfix 1, bugfix 2" async def test_no_update( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when no update available.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -49,12 +47,11 @@ async def test_no_update( async def test_update_str( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when update available with string from API.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.firmware_update_available.return_value = "New firmware available" + reolink_host.firmware_update_available.return_value = "New firmware available" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -69,21 +66,20 @@ async def test_update_str( async def test_update_firm( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, entity_name: str, ) -> None: """Test update state when update available with firmware info from reolink.com.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.sw_upload_progress.return_value = 100 - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.sw_upload_progress.return_value = 100 + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -117,9 +113,9 @@ async def test_update_firm( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.update_firmware.assert_called() + reolink_host.update_firmware.assert_called() - reolink_connect.sw_upload_progress.return_value = 50 + reolink_host.sw_upload_progress.return_value = 50 freezer.tick(POLL_PROGRESS) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -127,7 +123,7 @@ async def test_update_firm( assert hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] == 50 - reolink_connect.sw_upload_progress.return_value = 100 + reolink_host.sw_upload_progress.return_value = 100 freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -135,7 +131,7 @@ async def test_update_firm( assert not hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] is None - reolink_connect.update_firmware.side_effect = ReolinkError("Test error") + reolink_host.update_firmware.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( UPDATE_DOMAIN, @@ -144,7 +140,7 @@ async def test_update_firm( blocking=True, ) - reolink_connect.update_firmware.side_effect = ApiError( + reolink_host.update_firmware.side_effect = ApiError( "Test error", translation_key="firmware_rate_limit" ) with pytest.raises(HomeAssistantError): @@ -156,34 +152,31 @@ async def test_update_firm( ) # test _async_update_future - reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" - reolink_connect.firmware_update_available.return_value = False + reolink_host.camera_sw_version.return_value = "v3.3.0.226_23031644" + reolink_host.firmware_update_available.return_value = False freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF - reolink_connect.update_firmware.side_effect = None - @pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) async def test_update_firm_keeps_available( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, entity_name: str, ) -> None: """Test update entity keeps being available during update.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -196,7 +189,7 @@ async def test_update_firm_keeps_available( async def mock_update_firmware(*args, **kwargs) -> None: await asyncio.sleep(0.000005) - reolink_connect.update_firmware = mock_update_firmware + reolink_host.update_firmware = mock_update_firmware # test install with patch("homeassistant.components.reolink.update.POLL_PROGRESS", 0.000001): @@ -207,11 +200,9 @@ async def test_update_firm_keeps_available( blocking=True, ) - reolink_connect.session_active = False + reolink_host.session_active = False async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() # still available assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.session_active = True diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index 181249b8bff..1ebeaf902c8 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr -from .conftest import TEST_NVR_NAME, TEST_UID, TEST_UID_CAM +from .conftest import TEST_CAM_NAME, TEST_UID, TEST_UID_CAM from tests.common import MockConfigEntry @@ -103,21 +103,21 @@ DEV_ID_STANDALONE_CAM = f"{TEST_UID_CAM}" async def test_try_function( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, side_effect: ReolinkError, expected: HomeAssistantError, ) -> None: """Test try_function error translations using number entity.""" - reolink_connect.volume.return_value = 80 + reolink_host.volume.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_volume" - reolink_connect.set_volume.side_effect = side_effect + reolink_host.set_volume.side_effect = side_effect with pytest.raises(expected.__class__) as err: await hass.services.async_call( NUMBER_DOMAIN, @@ -128,8 +128,6 @@ async def test_try_function( assert err.value.translation_key == expected.translation_key - reolink_connect.set_volume.reset_mock(side_effect=True) - @pytest.mark.parametrize( ("identifiers"), @@ -141,12 +139,12 @@ async def test_try_function( async def test_get_device_uid_and_ch( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, device_registry: dr.DeviceRegistry, identifiers: set[tuple[str, str]], ) -> None: """Test get_device_uid_and_ch with multiple identifiers.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] dev_entry = device_registry.async_get_or_create( identifiers=identifiers, diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index 992e47f0575..6da9fbd29ca 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -64,14 +64,14 @@ def get_mock_session( ) async def test_playback_proxy( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, content_type: str, ) -> None: """Test successful playback proxy URL.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = get_mock_session(content_type=content_type) @@ -100,12 +100,12 @@ async def test_playback_proxy( async def test_proxy_get_source_error( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test error while getting source for playback proxy URL.""" - reolink_connect.get_vod_source.side_effect = ReolinkError(TEST_ERROR) + reolink_host.get_vod_source.side_effect = ReolinkError(TEST_ERROR) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -123,12 +123,11 @@ async def test_proxy_get_source_error( assert await response.content.read() == bytes(TEST_ERROR, "utf-8") assert response.status == HTTPStatus.BAD_REQUEST - reolink_connect.get_vod_source.side_effect = None async def test_proxy_invalid_config_entry_id( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: @@ -156,12 +155,12 @@ async def test_proxy_invalid_config_entry_id( async def test_playback_proxy_timeout( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test playback proxy URL with a timeout in the second chunk.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = get_mock_session([b"test", TimeoutError()], 4) @@ -190,13 +189,13 @@ async def test_playback_proxy_timeout( @pytest.mark.parametrize(("content_type"), [("video/x-flv"), ("text/html")]) async def test_playback_wrong_content( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, content_type: str, ) -> None: """Test playback proxy URL with a wrong content type in the response.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = get_mock_session(content_type=content_type) @@ -223,12 +222,12 @@ async def test_playback_wrong_content( async def test_playback_connect_error( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test playback proxy URL with a connection error.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = Mock() mock_session.get = AsyncMock(side_effect=ClientConnectionError(TEST_ERROR)) diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index bbaf70e0a9b..1474e90c8ea 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -599,7 +599,6 @@ async def test_fix_issue_aborted( "handler": "fake_integration", "reason": "not_given", "description_placeholders": None, - "result": None, } await ws_client.send_json({"id": 4, "type": "repairs/list_issues"}) diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 315f8113309..af7503a7007 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -667,3 +667,36 @@ async def test_availability_blocks_value_template( await hass.async_block_till_done() assert error in caplog.text + + +async def test_setup_get_basic_auth_utf8( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with basic auth using UTF-8 characters including Unicode char \u2018.""" + # Use a password with the Unicode character \u2018 (left single quotation mark) + aioclient_mock.get("http://localhost", status=HTTPStatus.OK, json={"key": "on"}) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "authentication": "basic", + "username": "test_user", + "password": "test\u2018password", # Password with Unicode char + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_ON diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py new file mode 100644 index 00000000000..01581c8ac68 --- /dev/null +++ b/tests/components/rest/test_data.py @@ -0,0 +1,550 @@ +"""Test REST data module logging improvements.""" + +from datetime import timedelta +import logging +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.rest import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_rest_data_log_warning_on_error_status( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status codes.""" + # Mock a 403 response with HTML content + aioclient_mock.get( + "http://example.com/api", + status=403, + text="Access Denied", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged + assert ( + "REST request to http://example.com/api returned status 403 " + "with text/html response" in caplog.text + ) + assert "Access Denied" in caplog.text + + +async def test_rest_data_no_warning_on_200_with_wrong_content_type( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for 200 status with wrong content.""" + # Mock a 200 response with HTML - users might still want to parse this + aioclient_mock.get( + "http://example.com/api", + status=200, + text="

This is HTML, not JSON!

", + headers={"Content-Type": "text/html; charset=utf-8"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for 200 status, even with HTML content type + assert ( + "REST request to http://example.com/api returned status 200" not in caplog.text + ) + + +async def test_rest_data_with_incorrect_charset_in_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that we can handle sites which provides an incorrect charset.""" + aioclient_mock.get( + "http://example.com/api", + status=200, + text="

Some html

", + headers={"Content-Type": "text/html; charset=utf-8"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "encoding": "windows-1250", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + with patch( + "tests.test_util.aiohttp.AiohttpClientMockResponse.text", + side_effect=UnicodeDecodeError("utf-8", b"", 1, 0, ""), + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + log_text = "Response charset came back as utf-8 but could not be decoded, continue with configured encoding windows-1250." + assert log_text in caplog.text + + caplog.clear() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Only log once as we only try once with automatic decoding + assert log_text not in caplog.text + + +async def test_rest_data_no_warning_on_success_json( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful JSON responses.""" + # Mock a successful JSON response + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok", "value": 42}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_no_warning_on_success_xml( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful XML responses.""" + # Mock a successful XML response + aioclient_mock.get( + "http://example.com/api", + status=200, + text='42', + headers={"Content-Type": "application/xml"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.root.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_warning_truncates_long_responses( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning truncates very long response bodies.""" + # Create a very long error message + long_message = "Error: " + "x" * 1000 + + aioclient_mock.get( + "http://example.com/api", + status=500, + text=long_message, + headers={"Content-Type": "text/plain"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with truncation + # Set the logger filter to only check our specific logger + caplog.set_level(logging.WARNING, logger="homeassistant.components.rest.data") + + # Verify the truncated warning appears + assert ( + "REST request to http://example.com/api returned status 500 " + "with text/plain response: Error: " + "x" * 493 + "..." in caplog.text + ) + + +async def test_rest_data_debug_logging_shows_response_details( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that debug logging shows response details.""" + caplog.set_level(logging.DEBUG) + + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"test": "data"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log + assert ( + "REST response from http://example.com/api: status=200, " + "content-type=application/json, length=" in caplog.text + ) + + +async def test_rest_data_no_content_type_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of responses without Content-Type header.""" + caplog.set_level(logging.DEBUG) + + # Mock response without Content-Type header + aioclient_mock.get( + "http://example.com/api", + status=200, + text="plain text response", + headers={}, # No Content-Type + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log shows "not set" + assert "content-type=not set" in caplog.text + # No warning for 200 with missing content-type + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_real_world_bom_blocking_scenario( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test real-world scenario where BOM blocks with HTML response.""" + # Mock BOM blocking response + bom_block_html = "

Your access is blocked due to automated access

" + + aioclient_mock.get( + "http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json", + status=403, + text=bom_block_html, + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": ("http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json"), + "method": "GET", + "sensor": [ + { + "name": "bom_temperature", + "value_template": ( + "{{ value_json.observations.data[0].air_temp }}" + ), + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with clear indication of the issue + assert ( + "REST request to http://www.bom.gov.au/fwo/IDN60901/" + "IDN60901.94767.json returned status 403 with text/html response" + ) in caplog.text + assert "Your access is blocked" in caplog.text + + +async def test_rest_data_warning_on_html_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status with HTML content.""" + # Mock a 404 response with HTML error page + aioclient_mock.get( + "http://example.com/api", + status=404, + text="

404 Not Found

", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should warn for error status with HTML + assert ( + "REST request to http://example.com/api returned status 404 " + "with text/html response" in caplog.text + ) + assert "

404 Not Found

" in caplog.text + + +async def test_rest_data_no_warning_on_json_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test POST request that returns JSON error - no warning expected.""" + aioclient_mock.post( + "http://example.com/api", + status=400, + text='{"error": "Invalid request payload"}', + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "POST", + "payload": '{"data": "test"}', + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.error }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for JSON error responses - users can parse these + assert ( + "REST request to http://example.com/api returned status 400" not in caplog.text + ) + + +async def test_rest_data_timeout_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test timeout error logging.""" + aioclient_mock.get( + "http://example.com/api", + exc=TimeoutError(), + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "timeout": 10, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check timeout error is logged or platform reports not ready + assert ( + "Timeout while fetching data: http://example.com/api" in caplog.text + or "Platform rest not ready yet" in caplog.text + ) + + +async def test_rest_data_boolean_params_converted_to_strings( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that boolean parameters are converted to lowercase strings.""" + # Mock the request and capture the actual URL + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "params": { + "boolTrue": True, + "boolFalse": False, + "stringParam": "test", + "intParam": 123, + }, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.status }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that the request was made with boolean values converted to strings + assert len(aioclient_mock.mock_calls) == 1 + method, url, data, headers = aioclient_mock.mock_calls[0] + + # Check that the URL query parameters have boolean values converted to strings + assert url.query["boolTrue"] == "true" + assert url.query["boolFalse"] == "false" + assert url.query["stringParam"] == "test" + assert url.query["intParam"] == "123" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index c688ff1b314..7bd84bbcd70 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the REST sensor platform.""" from http import HTTPStatus +import logging import ssl from unittest.mock import patch @@ -19,6 +20,14 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_METHOD, + CONF_NAME, + CONF_PARAMS, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, CONTENT_TYPE_JSON, SERVICE_RELOAD, STATE_UNAVAILABLE, @@ -135,14 +144,49 @@ async def test_setup_minimum( assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 -async def test_setup_encoding( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize( + ("content_text", "content_encoding", "headers", "expected_state"), + [ + # Test setup with non-utf8 encoding + pytest.param( + "tack själv", + "iso-8859-1", + None, + "tack själv", + id="simple_iso88591", + ), + # Test that configured encoding is used when no charset in Content-Type + pytest.param( + "Björk Guðmundsdóttir", + "iso-8859-1", + {"Content-Type": "text/plain"}, # No charset! + "Björk Guðmundsdóttir", + id="fallback_when_no_charset", + ), + # Test that charset in Content-Type overrides configured encoding + pytest.param( + "Björk Guðmundsdóttir", + "utf-8", + {"Content-Type": "text/plain; charset=utf-8"}, + "Björk Guðmundsdóttir", + id="charset_overrides_config", + ), + ], +) +async def test_setup_with_encoding_config( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + content_text: str, + content_encoding: str, + headers: dict[str, str] | None, + expected_state: str, ) -> None: - """Test setup with non-utf8 encoding.""" + """Test setup with encoding configuration in sensor config.""" aioclient_mock.get( "http://localhost", status=HTTPStatus.OK, - content="tack själv".encode(encoding="iso-8859-1"), + content=content_text.encode(content_encoding), + headers=headers, ) assert await async_setup_component( hass, @@ -159,7 +203,36 @@ async def test_setup_encoding( ) await hass.async_block_till_done() assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "tack själv" + assert hass.states.get("sensor.mysensor").state == expected_state + + +async def test_setup_with_charset_from_header( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with encoding auto-detected from Content-Type header.""" + # Test with ISO-8859-1 charset in Content-Type header + aioclient_mock.get( + "http://localhost", + status=HTTPStatus.OK, + content="Björk Guðmundsdóttir".encode("iso-8859-1"), + headers={"Content-Type": "text/plain; charset=iso-8859-1"}, + ) + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "name": "mysensor", + # No encoding config - should use charset from header. + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" @pytest.mark.parametrize( @@ -978,6 +1051,124 @@ async def test_update_with_failed_get( assert "Empty reply" in caplog.text +async def test_query_param_dict_value( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test dict values in query params are handled for backward compatibility.""" + # Mock response + aioclient_mock.post( + "https://www.envertecportal.com/ApiInverters/QueryTerminalReal", + status=HTTPStatus.OK, + json={"Data": {"QueryResults": [{"POWER": 1500}]}}, + ) + + # This test checks that when template_complex processes a string that looks like + # a dict/list, it converts it to an actual dict/list, which then needs to be + # handled by our backward compatibility code + with caplog.at_level(logging.DEBUG, logger="homeassistant.components.rest.data"): + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + CONF_RESOURCE: ( + "https://www.envertecportal.com/ApiInverters/" + "QueryTerminalReal" + ), + CONF_METHOD: "POST", + CONF_PARAMS: { + "page": "1", + "perPage": "20", + "orderBy": "SN", + # When processed by template.render_complex, certain + # strings might be converted to dicts/lists if they + # look like JSON + "whereCondition": ( + "{{ {'STATIONID': 'A6327A17797C1234'} }}" + ), # Template that evaluates to dict + }, + "sensor": [ + { + CONF_NAME: "Solar MPPT1 Power", + CONF_VALUE_TEMPLATE: ( + "{{ value_json.Data.QueryResults[0].POWER }}" + ), + CONF_DEVICE_CLASS: "power", + CONF_UNIT_OF_MEASUREMENT: "W", + CONF_FORCE_UPDATE: True, + "state_class": "measurement", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + + # The sensor should be created successfully with backward compatibility + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + state = hass.states.get("sensor.solar_mppt1_power") + assert state is not None + assert state.state == "1500" + + # Check that a debug message was logged about the parameter conversion + assert "REST query parameter 'whereCondition' has type" in caplog.text + assert "converting to string" in caplog.text + + +async def test_query_param_json_string_preserved( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that JSON strings in query params are preserved and not converted to dicts.""" + # Mock response + aioclient_mock.get( + "https://api.example.com/data", + status=HTTPStatus.OK, + json={"value": 42}, + ) + + # Config with JSON string (quoted) - should remain a string + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + CONF_RESOURCE: "https://api.example.com/data", + CONF_METHOD: "GET", + CONF_PARAMS: { + "filter": '{"type": "sensor", "id": 123}', # JSON string + "normal": "value", + }, + "sensor": [ + { + CONF_NAME: "Test Sensor", + CONF_VALUE_TEMPLATE: "{{ value_json.value }}", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + + # Check the sensor was created + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == "42" + + # Verify the request was made with the JSON string intact + assert len(aioclient_mock.mock_calls) == 1 + method, url, data, headers = aioclient_mock.mock_calls[0] + assert url.query["filter"] == '{"type": "sensor", "id": 123}' + assert url.query["normal"] == "value" + + async def test_reload(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Verify we can reload reset sensors.""" diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 97ef29dfaca..b9c1096f26a 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -290,6 +290,7 @@ async def test_rest_command_get_response_plaintext( assert len(aioclient_mock.mock_calls) == 1 assert response["content"] == "success" assert response["status"] == 200 + assert response["headers"] == {"content-type": "text/plain"} async def test_rest_command_get_response_json( @@ -314,6 +315,7 @@ async def test_rest_command_get_response_json( assert response["content"]["status"] == "success" assert response["content"]["number"] == 42 assert response["status"] == 200 + assert response["headers"] == {"content-type": "application/json"} async def test_rest_command_get_response_malformed_json( @@ -326,7 +328,7 @@ async def test_rest_command_get_response_malformed_json( aioclient_mock.get( TEST_URL, - content='{"status": "failure", 42', + content=b'{"status": "failure", 42', headers={"content-type": "application/json"}, ) @@ -379,3 +381,27 @@ async def test_rest_command_get_response_none( ) assert not response + + +async def test_rest_command_response_iter_chunked( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Ensure response is consumed when return_response is False.""" + await setup_component() + + png = base64.decodebytes( + b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQ" + b"UAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" + ) + aioclient_mock.get(TEST_URL, content=png) + + with patch("aiohttp.StreamReader.iter_chunked", autospec=True) as mock_iter_chunked: + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + # Ensure the response is not returned + assert response is None + + # Verify iter_chunked was called with a chunk size + assert mock_iter_chunked.called diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py index 8caef1fbfc4..d27d39071a0 100644 --- a/tests/components/risco/test_alarm_control_panel.py +++ b/tests/components/risco/test_alarm_control_panel.py @@ -35,6 +35,7 @@ FIRST_LOCAL_ENTITY_ID = "alarm_control_panel.name_0" SECOND_LOCAL_ENTITY_ID = "alarm_control_panel.name_1" CODES_REQUIRED_OPTIONS = {"code_arm_required": True, "code_disarm_required": True} +CODES_NOT_REQUIRED_OPTIONS = {"code_arm_required": False, "code_disarm_required": False} TEST_RISCO_TO_HA = { "arm": AlarmControlPanelState.ARMED_AWAY, "partial_arm": AlarmControlPanelState.ARMED_HOME, @@ -388,7 +389,8 @@ async def test_cloud_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_correct_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -452,7 +454,58 @@ async def test_cloud_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_cloud_sets_without_code( + hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud +) -> None: + """Test settings the various modes when code is not required.""" + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_CLOUD_ENTITY_ID, 0, "C" + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_CLOUD_ENTITY_ID, 1, "C" + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_CLOUD_ENTITY_ID, + 0, + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_CLOUD_ENTITY_ID, + 1, + ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_incorrect_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -837,7 +890,8 @@ async def test_local_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_correct_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -931,7 +985,8 @@ async def test_local_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_incorrect_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -1020,3 +1075,87 @@ async def test_local_sets_with_incorrect_code( two_part_local_alarm[1], **code, ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_local_sets_without_code( + hass: HomeAssistant, two_part_local_alarm, setup_risco_local +) -> None: + """Test settings the various modes when code is not required.""" + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + "C", + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + "C", + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 7958f17a696..6974bc5fccc 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -199,7 +199,7 @@ async def test_config_flow_failures_code_login( async def test_options_flow_drawables( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry ) -> None: """Test that the options flow works.""" with patch("homeassistant.components.roborock.roborock_storage"): @@ -239,8 +239,11 @@ async def test_reauth_flow( assert result["step_id"] == "reauth_confirm" # Request a new code - with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + with ( + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ), + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -250,9 +253,12 @@ async def test_reauth_flow( assert result["type"] is FlowResultType.FORM new_user_data = deepcopy(USER_DATA) new_user_data.rriot.s = "new_password_hash" - with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", - return_value=new_user_data, + with ( + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ), + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 5d6e7a599bd..aa7da07d499 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -142,12 +142,14 @@ async def test_cloud_command( @pytest.mark.parametrize( - ("in_cleaning_int", "expected_command"), + ("in_cleaning_int", "in_returning_int", "expected_command"), [ - (0, RoborockCommand.APP_START), - (1, RoborockCommand.APP_START), - (2, RoborockCommand.RESUME_ZONED_CLEAN), - (3, RoborockCommand.RESUME_SEGMENT_CLEAN), + (0, 1, RoborockCommand.APP_CHARGE), + (0, 0, RoborockCommand.APP_START), + (1, 0, RoborockCommand.APP_START), + (2, 0, RoborockCommand.RESUME_ZONED_CLEAN), + (3, 0, RoborockCommand.RESUME_SEGMENT_CLEAN), + (4, 0, RoborockCommand.APP_RESUME_BUILD_MAP), ], ) async def test_resume_cleaning( @@ -155,11 +157,13 @@ async def test_resume_cleaning( bypass_api_fixture, mock_roborock_entry: MockConfigEntry, in_cleaning_int: int, + in_returning_int: int, expected_command: RoborockCommand, ) -> None: """Test resuming clean on start button when a clean is paused.""" prop = copy.deepcopy(PROP) prop.status.in_cleaning = in_cleaning_int + prop.status.in_returning = in_returning_int with patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", return_value=prop, diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py index c3aec4f0968..bc5022a7724 100644 --- a/tests/components/roku/test_binary_sensor.py +++ b/tests/components/roku/test_binary_sensor.py @@ -9,7 +9,11 @@ from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON from homeassistant.components.roku.const import DOMAIN from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from . import UPNP_SERIAL @@ -77,12 +81,13 @@ async def test_roku_binary_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "7.5.0" assert device_entry.hw_version == "4200X" - assert device_entry.suggested_area is None + assert device_entry.area_id is None @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_rokutv_binary_sensors( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, @@ -158,4 +163,6 @@ async def test_rokutv_binary_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "9.2.0" assert device_entry.hw_version == "7820X" - assert device_entry.suggested_area == "Living room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living room").id + ) diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 5f8a41d16ac..2607c79086a 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -52,15 +52,19 @@ from homeassistant.const import ( SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, STATE_IDLE, + STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, - STATE_STANDBY, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -100,7 +104,7 @@ async def test_setup( assert device_entry.entry_type is None assert device_entry.sw_version == "7.5.0" assert device_entry.hw_version == "4200X" - assert device_entry.suggested_area is None + assert device_entry.area_id is None @pytest.mark.parametrize("mock_device", ["roku/roku3-idle.json"], indirect=True) @@ -112,12 +116,13 @@ async def test_idle_setup( """Test setup with idle device.""" state = hass.states.get(MAIN_ENTITY_ID) assert state - assert state.state == STATE_STANDBY + assert state.state == STATE_OFF @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_tv_setup( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, @@ -146,7 +151,9 @@ async def test_tv_setup( assert device_entry.entry_type is None assert device_entry.sw_version == "9.2.0" assert device_entry.hw_version == "7820X" - assert device_entry.suggested_area == "Living room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living room").id + ) @pytest.mark.parametrize( diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py index e65424e3e66..72f57729cc4 100644 --- a/tests/components/roku/test_sensor.py +++ b/tests/components/roku/test_sensor.py @@ -12,7 +12,11 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from . import UPNP_SERIAL @@ -60,12 +64,13 @@ async def test_roku_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "7.5.0" assert device_entry.hw_version == "4200X" - assert device_entry.suggested_area is None + assert device_entry.area_id is None @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_rokutv_sensors( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, @@ -106,4 +111,6 @@ async def test_rokutv_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "9.2.0" assert device_entry.hw_version == "7820X" - assert device_entry.suggested_area == "Living room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living room").id + ) diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 5b6766f7eb9..d567712dad8 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -77,12 +77,12 @@ DISCOVERY_DEVICES = [ DHCP_DISCOVERY_DEVICES_WITHOUT_MATCHING_IP = [ DhcpServiceInfo( ip="4.4.4.4", - macaddress="50:14:79:DD:EE:FF", + macaddress="501479ddeeff", hostname="irobot-blid", ), DhcpServiceInfo( ip="5.5.5.5", - macaddress="80:A5:89:DD:EE:FF", + macaddress="80a589ddeeff", hostname="roomba-blid", ), ] diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 8eb77006061..25925ac3865 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '8381BE13', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 81091e1d5a8..15922f76b9f 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -84,6 +84,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.set_treble = AsyncMock() zone.set_turn_on_volume = AsyncMock() zone.set_loudness = AsyncMock() + zone.restore_preset = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/fixtures/get_sources.json b/tests/components/russound_rio/fixtures/get_sources.json index e39d702b8a1..a9f4b4e14af 100644 --- a/tests/components/russound_rio/fixtures/get_sources.json +++ b/tests/components/russound_rio/fixtures/get_sources.json @@ -1,7 +1,14 @@ { "1": { "name": "Aux", - "type": "Miscellaneous Audio" + "type": "RNET AM/FM Tuner (Internal)", + "presets": { + "1": "WOOD", + "2": "89.7 MHz FM", + "7": "WWKR", + "8": "WKLA", + "11": "WGN" + } }, "2": { "name": "Spotify", diff --git a/tests/components/russound_rio/fixtures/get_zones.json b/tests/components/russound_rio/fixtures/get_zones.json index e1077944593..b51c93875f1 100644 --- a/tests/components/russound_rio/fixtures/get_zones.json +++ b/tests/components/russound_rio/fixtures/get_zones.json @@ -7,7 +7,8 @@ "volume": "10", "status": "ON", "enabled": "True", - "current_source": "1" + "current_source": "1", + "enabled_sources": [1, 2] }, "2": { "name": "Kitchen", diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index e3185a06b24..b02f80f1dfd 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -21,7 +21,6 @@ '00:11:22:33:44:55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Russound', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/russound_rio/snapshots/test_media_browser.ambr b/tests/components/russound_rio/snapshots/test_media_browser.ambr new file mode 100644 index 00000000000..7c3df31a69b --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_media_browser.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_browse_media_root + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'presets', + 'thumbnail': 'https://brands.home-assistant.io/_/russound_rio/logo.png', + 'title': 'Presets', + }), + ]) +# --- +# name: test_browse_presets + list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,1', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WOOD', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,2', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': '89.7 MHz FM', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,7', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WWKR', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,8', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WKLA', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,11', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WGN', + }), + ]) +# --- diff --git a/tests/components/russound_rio/test_media_browser.py b/tests/components/russound_rio/test_media_browser.py new file mode 100644 index 00000000000..d2d67e70aeb --- /dev/null +++ b/tests/components/russound_rio/test_media_browser.py @@ -0,0 +1,61 @@ +"""Tests for the Russound RIO media browser.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import ENTITY_ID_ZONE_1 + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_browse_media_root( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the root browse page.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID_ZONE_1, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_presets( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the presets browse page.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID_ZONE_1, + "media_content_type": "presets", + "media_content_id": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 04e1057565d..d8eacd5f30b 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -9,10 +9,13 @@ import pytest from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, ) from homeassistant.const import ( @@ -32,7 +35,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import mock_state_update, setup_integration from .const import ENTITY_ID_ZONE_1 @@ -253,3 +256,94 @@ async def test_media_seek( mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with( 100 ) + + +async def test_play_media_preset_item_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test playing media with a preset item id.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].restore_preset.assert_called_once_with( + 1 + ) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "1,2", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].select_source.assert_called_once_with( + 1 + ) + mock_russound_client.controllers[1].zones[1].restore_preset.assert_called_with(2) + + with pytest.raises( + ServiceValidationError, + match="The specified preset is not available for this source: 10", + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "10", + }, + blocking=True, + ) + + with pytest.raises( + ServiceValidationError, match="Preset must be an integer, got: UNKNOWN_PRESET" + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "UNKNOWN_PRESET", + }, + blocking=True, + ) + + +async def test_play_media_unknown_type( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test playing media with an unsupported content type.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises( + HomeAssistantError, + match="Unsupported media type for Russound zone: unsupported_content_type", + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "unsupported_content_type", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + ) diff --git a/tests/components/ruuvitag_ble/fixtures.py b/tests/components/ruuvitag_ble/fixtures.py index 5d6ac9ea470..94ed1e00331 100644 --- a/tests/components/ruuvitag_ble/fixtures.py +++ b/tests/components/ruuvitag_ble/fixtures.py @@ -12,7 +12,7 @@ NOT_RUUVITAG_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -RUUVITAG_SERVICE_INFO = BluetoothServiceInfo( +RUUVI_V5_SERVICE_INFO = BluetoothServiceInfo( name="RuuviTag 0911", address="01:03:05:07:09:11", # Ignored (the payload encodes the correct MAC) rssi=-60, @@ -23,5 +23,16 @@ RUUVITAG_SERVICE_INFO = BluetoothServiceInfo( service_uuids=[], source="local", ) +RUUVI_V6_SERVICE_INFO = BluetoothServiceInfo( + name="Ruuvi 1234", + address="01:03:05:07:12:34", # Ignored (the payload encodes the correct MAC) + rssi=-60, + manufacturer_data={ + 1177: b"\x06\x17\x0cVh\xc7\x9e\x00p\x00\xc9\x05\x01\xd9J\xcd\x00L\x88O", + }, + service_data={}, + service_uuids=[], + source="local", +) CONFIGURED_NAME = "RuuviTag EFAF" CONFIGURED_PREFIX = "ruuvitag_efaf" diff --git a/tests/components/ruuvitag_ble/snapshots/test_sensor.ambr b/tests/components/ruuvitag_ble/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2641297bc76 --- /dev/null +++ b/tests/components/ruuvitag_ble/snapshots/test_sensor.ambr @@ -0,0 +1,1013 @@ +# serializer version: 1 +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration total', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_total', + 'unique_id': '01:03:05:07:09:11-acceleration_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.82', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_x-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_x', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration X', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_x', + 'unique_id': '01:03:05:07:09:11-acceleration_x', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_x-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration X', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_x', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-7.02', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_y-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_y', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration Y', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_y', + 'unique_id': '01:03:05:07:09:11-acceleration_y', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_y-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration Y', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_y', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.39', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_z-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_z', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Acceleration Z', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration_z', + 'unique_id': '01:03:05:07:09:11-acceleration_z', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_acceleration_z-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Acceleration Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_acceleration_z', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.51', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'RuuviTag EFAF Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.84', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_movement_counter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_movement_counter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Movement counter', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'movement_counter', + 'unique_id': '01:03:05:07:09:11-movement_counter', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_movement_counter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag EFAF Movement counter', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_movement_counter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '114', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'RuuviTag EFAF Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1013.54', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'RuuviTag EFAF Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'RuuviTag EFAF Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_efaf_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:09:11-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v5][sensor.ruuvitag_efaf_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'RuuviTag EFAF Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_efaf_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2395', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-carbon_dioxide', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'RuuviTag 884F Carbon dioxide', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '201', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'RuuviTag 884F Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.3', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-illuminance', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'RuuviTag 884F Illuminance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13027', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_nox_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_nox_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'NOx index', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nox_index', + 'unique_id': '01:03:05:07:12:34-nox_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_nox_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag 884F NOx index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_nox_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-pm25', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'RuuviTag 884F PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.2', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'RuuviTag 884F Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1011.02', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'RuuviTag 884F Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:03:05:07:12:34-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'RuuviTag 884F Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.5', + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_voc_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ruuvitag_884f_voc_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOC index', + 'platform': 'ruuvitag_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voc_index', + 'unique_id': '01:03:05:07:12:34-voc_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[v6][sensor.ruuvitag_884f_voc_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RuuviTag 884F VOC index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ruuvitag_884f_voc_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- diff --git a/tests/components/ruuvitag_ble/test_config_flow.py b/tests/components/ruuvitag_ble/test_config_flow.py index 3414fa34536..5259511fc0f 100644 --- a/tests/components/ruuvitag_ble/test_config_flow.py +++ b/tests/components/ruuvitag_ble/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.ruuvitag_ble.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .fixtures import CONFIGURED_NAME, NOT_RUUVITAG_SERVICE_INFO, RUUVITAG_SERVICE_INFO +from .fixtures import CONFIGURED_NAME, NOT_RUUVITAG_SERVICE_INFO, RUUVI_V5_SERVICE_INFO from tests.common import MockConfigEntry @@ -24,7 +24,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -36,7 +36,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == CONFIGURED_NAME - assert result2["result"].unique_id == RUUVITAG_SERVICE_INFO.address + assert result2["result"].unique_id == RUUVI_V5_SERVICE_INFO.address async def test_async_step_bluetooth_not_ruuvitag(hass: HomeAssistant) -> None: @@ -64,7 +64,7 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: """Test setup from service info cache with devices found.""" with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -77,18 +77,18 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"address": RUUVITAG_SERVICE_INFO.address}, + user_input={"address": RUUVI_V5_SERVICE_INFO.address}, ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == CONFIGURED_NAME - assert result2["result"].unique_id == RUUVITAG_SERVICE_INFO.address + assert result2["result"].unique_id == RUUVI_V5_SERVICE_INFO.address async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -99,7 +99,7 @@ async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) - entry = MockConfigEntry( domain=DOMAIN, - unique_id=RUUVITAG_SERVICE_INFO.address, + unique_id=RUUVI_V5_SERVICE_INFO.address, ) entry.add_to_hass(hass) @@ -108,7 +108,7 @@ async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) - ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"address": RUUVITAG_SERVICE_INFO.address}, + user_input={"address": RUUVI_V5_SERVICE_INFO.address}, ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -120,13 +120,13 @@ async def test_async_step_user_with_found_devices_already_setup( """Test setup from service info cache with devices found.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=RUUVITAG_SERVICE_INFO.address, + unique_id=RUUVI_V5_SERVICE_INFO.address, ) entry.add_to_hass(hass) with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -140,14 +140,14 @@ async def test_async_step_bluetooth_devices_already_setup(hass: HomeAssistant) - """Test we can't start a flow if there is already a config entry.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=RUUVITAG_SERVICE_INFO.address, + unique_id=RUUVI_V5_SERVICE_INFO.address, ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -158,7 +158,7 @@ async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -166,7 +166,7 @@ async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_in_progress" @@ -179,14 +179,14 @@ async def test_async_step_user_takes_precedence_over_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, - data=RUUVITAG_SERVICE_INFO, + data=RUUVI_V5_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" with patch( "homeassistant.components.ruuvitag_ble.config_flow.async_discovered_service_info", - return_value=[RUUVITAG_SERVICE_INFO], + return_value=[RUUVI_V5_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -199,9 +199,9 @@ async def test_async_step_user_takes_precedence_over_discovery( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"address": RUUVITAG_SERVICE_INFO.address}, + user_input={"address": RUUVI_V5_SERVICE_INFO.address}, ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == CONFIGURED_NAME assert result2["data"] == {} - assert result2["result"].unique_id == RUUVITAG_SERVICE_INFO.address + assert result2["result"].unique_id == RUUVI_V5_SERVICE_INFO.address diff --git a/tests/components/ruuvitag_ble/test_sensor.py b/tests/components/ruuvitag_ble/test_sensor.py index 14826a692a6..edeb6a4c2b5 100644 --- a/tests/components/ruuvitag_ble/test_sensor.py +++ b/tests/components/ruuvitag_ble/test_sensor.py @@ -3,47 +3,37 @@ from __future__ import annotations import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ruuvitag_ble.const import DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo -from .fixtures import CONFIGURED_NAME, CONFIGURED_PREFIX, RUUVITAG_SERVICE_INFO +from .fixtures import RUUVI_V5_SERVICE_INFO, RUUVI_V6_SERVICE_INFO -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform from tests.components.bluetooth import inject_bluetooth_service_info -@pytest.mark.usefixtures("enable_bluetooth") -async def test_sensors(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "service_info", [RUUVI_V5_SERVICE_INFO, RUUVI_V6_SERVICE_INFO], ids=("v5", "v6") +) +async def test_sensors( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + service_info: BluetoothServiceInfo, +) -> None: """Test the RuuviTag BLE sensors.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=RUUVITAG_SERVICE_INFO.address) + entry = MockConfigEntry(domain=DOMAIN, unique_id=service_info.address) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 0 - inject_bluetooth_service_info( - hass, - RUUVITAG_SERVICE_INFO, - ) + inject_bluetooth_service_info(hass, service_info) await hass.async_block_till_done() - assert len(hass.states.async_all()) >= 4 - - for sensor, value, unit, state_class in ( - ("temperature", "7.2", "°C", "measurement"), - ("humidity", "61.84", "%", "measurement"), - ("pressure", "1013.54", "hPa", "measurement"), - ("voltage", "2395", "mV", "measurement"), - ): - state = hass.states.get(f"sensor.{CONFIGURED_PREFIX}_{sensor}") - assert state is not None - assert state.state == value - name_lower = state.attributes[ATTR_FRIENDLY_NAME].lower() - assert name_lower == f"{CONFIGURED_NAME} {sensor}".lower() - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == unit - assert state.attributes[ATTR_STATE_CLASS] == state_class + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index b29b824a7dd..4be166ecf25 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -22,7 +22,6 @@ 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -32,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -57,7 +55,6 @@ '123456', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -67,7 +64,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -96,7 +92,6 @@ 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -106,7 +101,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index d63e5a7ae2a..dd6b21ab5e5 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -161,6 +161,7 @@ async def test_user_legacy(hass: HomeAssistant) -> None: assert result["data"][CONF_METHOD] == METHOD_LEGACY assert result["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result["data"][CONF_MODEL] is None + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id is None @@ -195,6 +196,7 @@ async def test_user_legacy_does_not_ok_first_time(hass: HomeAssistant) -> None: assert result3["data"][CONF_METHOD] == METHOD_LEGACY assert result3["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result3["data"][CONF_MODEL] is None + assert result3["data"][CONF_PORT] == 55000 assert result3["result"].unique_id is None @@ -224,6 +226,7 @@ async def test_user_websocket(hass: HomeAssistant) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -272,6 +275,7 @@ async def test_user_encrypted_websocket( assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung" assert result4["data"][CONF_MODEL] == "UE48JU6400" + assert result4["data"][CONF_PORT] == 8000 assert result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] is None assert result4["data"][CONF_TOKEN] == "037739871315caef138547b03e348b72" assert result4["data"][CONF_SESSION_ID] == "1" @@ -402,6 +406,7 @@ async def test_user_websocket_auth_retry(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.20.43.21" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -464,6 +469,7 @@ async def test_ssdp(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -522,6 +528,7 @@ async def test_ssdp_noprefix(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -557,6 +564,7 @@ async def test_ssdp_legacy_missing_auth(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -599,6 +607,7 @@ async def test_ssdp_websocket_success_populates_mac_address_and_ssdp_location( assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert ( result["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] == "http://10.10.12.34:7676/smp_15_" @@ -630,6 +639,7 @@ async def test_ssdp_websocket_success_populates_mac_address_and_main_tv_ssdp_loc assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert ( result["data"][CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" @@ -681,6 +691,7 @@ async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_l assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result4["data"][CONF_MODEL] == "UE48JU6400" + assert result4["data"][CONF_PORT] == 8000 assert ( result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] == "http://10.10.12.34:7676/smp_15_" @@ -887,6 +898,7 @@ async def test_dhcp_wireless(hass: HomeAssistant) -> None: assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE48JU6400" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "223da676-497a-4e06-9507-5e27ec4f0fb3" @@ -919,6 +931,7 @@ async def test_dhcp_wired(hass: HomeAssistant, rest_api: Mock) -> None: assert result["data"][CONF_MAC] == "aa:ee:tt:hh:ee:rr" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE43LS003" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -1020,6 +1033,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -1129,6 +1143,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" + assert result["data"][CONF_PORT] == 8002 remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -1180,6 +1195,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" assert result["data"][CONF_MAC] == "gg:ee:tt:mm:aa:cc" + assert result["data"][CONF_PORT] == 8002 remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -2091,6 +2107,7 @@ async def test_ssdp_update_mac(hass: HomeAssistant) -> None: assert entry.data[CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert entry.data[CONF_MODEL] == "fake_model" assert entry.data[CONF_MAC] is None + assert entry.data[CONF_PORT] == 8002 assert entry.unique_id == "123" device_info = deepcopy(MOCK_DEVICE_INFO) diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index fef2ff745cd..6fd6314c6bb 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -131,16 +131,11 @@ def schedule_setup( return _schedule_setup -async def test_invalid_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, {"name with space": None}]) +async def test_invalid_config(hass: HomeAssistant, invalid_config) -> None: """Test invalid configs.""" - invalid_configs = [ - None, - {}, - {"name with space": None}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) @pytest.mark.parametrize( diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index a7f94b80038..1b6cc3f1cdb 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'test', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Schlage', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0', 'via_device_id': None, }) diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index 0ffb860179d..446dbd0a0bf 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -376,6 +376,7 @@ async def test_get_action_capabilities( { "name": "cycle", "optional": True, + "required": False, "type": "boolean", "default": True, }, @@ -391,6 +392,7 @@ async def test_get_action_capabilities( { "name": "cycle", "optional": True, + "required": False, "type": "boolean", "default": True, }, @@ -476,6 +478,7 @@ async def test_get_action_capabilities_legacy( { "name": "cycle", "optional": True, + "required": False, "type": "boolean", "default": True, }, @@ -491,6 +494,7 @@ async def test_get_action_capabilities_legacy( { "name": "cycle", "optional": True, + "required": False, "type": "boolean", "default": True, }, diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index fc35757fa67..83788701877 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -276,6 +276,7 @@ async def test_get_condition_capabilities( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] @@ -301,6 +302,7 @@ async def test_get_condition_capabilities( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] @@ -336,6 +338,7 @@ async def test_get_condition_capabilities_legacy( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] @@ -361,6 +364,7 @@ async def test_get_condition_capabilities_legacy( { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index dbb4e23d785..1d8f8b07ad7 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -310,18 +310,21 @@ async def test_get_trigger_capabilities( { "name": "from", "optional": True, + "required": False, "type": "select", "options": [], }, { "name": "to", "optional": True, + "required": False, "type": "select", "options": [], }, { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] @@ -341,18 +344,21 @@ async def test_get_trigger_capabilities( { "name": "from", "optional": True, + "required": False, "type": "select", "options": [("option1", "option1"), ("option2", "option2")], }, { "name": "to", "optional": True, + "required": False, "type": "select", "options": [("option1", "option1"), ("option2", "option2")], }, { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] @@ -380,18 +386,21 @@ async def test_get_trigger_capabilities_unknown( { "name": "from", "optional": True, + "required": False, "type": "select", "options": [], }, { "name": "to", "optional": True, + "required": False, "type": "select", "options": [], }, { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] @@ -421,18 +430,21 @@ async def test_get_trigger_capabilities_legacy( { "name": "from", "optional": True, + "required": False, "type": "select", "options": [], }, { "name": "to", "optional": True, + "required": False, "type": "select", "options": [], }, { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] @@ -452,18 +464,21 @@ async def test_get_trigger_capabilities_legacy( { "name": "from", "optional": True, + "required": False, "type": "select", "options": [("option1", "option1"), ("option2", "option2")], }, { "name": "to", "optional": True, + "required": False, "type": "select", "options": [("option1", "option1"), ("option2", "option2")], }, { "name": "for", "optional": True, + "required": False, "type": "positive_time_period_dict", }, ] diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index 80ee847cb55..ba075d764f5 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -22,7 +22,6 @@ 'ABC999111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -32,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': 'Hallway', 'sw_version': 'SKY30046', 'via_device_id': None, }), @@ -57,7 +55,6 @@ 'AAZZAAZZ', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -67,7 +64,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0987654321', - 'suggested_area': 'Kitchen', 'sw_version': 'PUR00111', 'via_device_id': None, }), @@ -92,7 +88,6 @@ 'BBZZBBZZ', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -102,7 +97,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0987654329', - 'suggested_area': 'Bedroom', 'sw_version': 'PUR00111', 'via_device_id': None, }), @@ -123,7 +117,6 @@ 'AABBCC', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -133,7 +126,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'V17', 'via_device_id': , }), diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 75dbdc88840..05a4fb731d1 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -14,16 +14,13 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er -from . import ENTRY_CONFIG - -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -154,87 +151,3 @@ async def test_select_set_option( state = hass.states.get("select.kitchen_light") assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize( - "load_platforms", - [[Platform.SELECT]], -) -async def test_deprecated_horizontal_swing_select( - hass: HomeAssistant, - load_platforms: list[Platform], - mock_client: MagicMock, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the deprecated horizontal swing select entity.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=ENTRY_CONFIG, - entry_id="1", - unique_id="firstnamelastname", - version=2, - ) - - config_entry.add_to_hass(hass) - - entity_registry.async_get_or_create( - SELECT_DOMAIN, - DOMAIN, - "ABC999111-horizontalSwing", - config_entry=config_entry, - disabled_by=None, - has_entity_name=True, - suggested_object_id="hallway_horizontal_swing", - ) - - with patch("homeassistant.components.sensibo.PLATFORMS", load_platforms): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" - - # No issue created without automation or script - assert issue_registry.issues == {} - - with ( - patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), - patch( - # Patch check for automation, that one exist - "homeassistant.components.sensibo.select.automations_with_entity", - return_value=["automation.test"], - ), - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done(True) - - # Issue is created when entity is enabled and automation/script exist - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") - assert issue - assert issue.translation_key == "deprecated_entity_horizontalswing" - assert hass.states.get("select.hallway_horizontal_swing") - assert entity_registry.async_is_registered("select.hallway_horizontal_swing") - - # Disabling the entity should remove the entity and remove the issue - # once the integration is reloaded - entity_registry.async_update_entity( - state.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - - with ( - patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), - patch( - "homeassistant.components.sensibo.select.automations_with_entity", - return_value=["automation.test"], - ), - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done(True) - - # Disabling the entity and reloading has removed the entity and issue - assert not hass.states.get("select.hallway_horizontal_swing") - assert not entity_registry.async_is_registered("select.hallway_horizontal_swing") - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") - assert not issue diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 2df13b697da..1b9810a8250 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.components.sensor.const import DEVICE_CLASS_STATE_CLASSES from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, DEGREE, @@ -44,6 +45,7 @@ from homeassistant.const import ( from tests.common import MockEntity UNITS_OF_MEASUREMENT = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: CONCENTRATION_GRAMS_PER_CUBIC_METER, SensorDeviceClass.APPARENT_POWER: UnitOfApparentPower.VOLT_AMPERE, SensorDeviceClass.AQI: None, SensorDeviceClass.AREA: UnitOfArea.SQUARE_METERS, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 1c87845c2c7..88bec54c936 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -125,7 +125,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert len(conditions) == 54 + assert len(conditions) == 55 assert conditions == unordered(expected_conditions) @@ -329,12 +329,14 @@ async def test_get_condition_capabilities( "description": {"suffix": PERCENTAGE}, "name": "above", "optional": True, + "required": False, "type": "float", }, { "description": {"suffix": PERCENTAGE}, "name": "below", "optional": True, + "required": False, "type": "float", }, ] @@ -398,12 +400,14 @@ async def test_get_condition_capabilities_legacy( "description": {"suffix": PERCENTAGE}, "name": "above", "optional": True, + "required": False, "type": "float", }, { "description": {"suffix": PERCENTAGE}, "name": "below", "optional": True, + "required": False, "type": "float", }, ] diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index bb57797e6dd..31bd0d2be55 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -126,7 +126,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 54 + assert len(triggers) == 55 assert triggers == unordered(expected_triggers) @@ -277,15 +277,22 @@ async def test_get_trigger_capabilities( "description": {"suffix": PERCENTAGE}, "name": "above", "optional": True, + "required": False, "type": "float", }, { "description": {"suffix": PERCENTAGE}, "name": "below", "optional": True, + "required": False, "type": "float", }, - {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + }, ] } triggers = await async_get_device_automations( @@ -347,15 +354,22 @@ async def test_get_trigger_capabilities_legacy( "description": {"suffix": PERCENTAGE}, "name": "above", "optional": True, + "required": False, "type": "float", }, { "description": {"suffix": PERCENTAGE}, "name": "below", "optional": True, + "required": False, "type": "float", }, - {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + }, ] } triggers = await async_get_device_automations( diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 98fb9d6604a..ce78edfe481 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -11,8 +11,12 @@ from unittest.mock import patch import pytest from homeassistant.components import sensor -from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.number import ( + AMBIGUOUS_UNITS as NUMBER_AMBIGUOUS_UNITS, + NumberDeviceClass, +) from homeassistant.components.sensor import ( + AMBIGUOUS_UNITS as SENSOR_AMBIGUOUS_UNITS, DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, DOMAIN, @@ -159,12 +163,47 @@ async def test_temperature_conversion_wrong_device_class( assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) await hass.async_block_till_done() - # Check temperature is not converted + # Check compatible unit is applied state = hass.states.get(entity0.entity_id) assert state.state == "0.0" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.FAHRENHEIT +@pytest.mark.parametrize( + ("ambiguous_unit", "normalized_unit"), + [ + (ambiguous_unit, normalized_unit) + for ambiguous_unit, normalized_unit in sensor.AMBIGUOUS_UNITS.items() + ], +) +async def test_ambiguous_unit_of_measurement_compat( + hass: HomeAssistant, ambiguous_unit: str, normalized_unit: str +) -> None: + """Test ambiguous native_unit_of_measurement values are corrected.""" + entity0 = MockSensor( + name="Test", + native_value="0.0", + native_unit_of_measurement=ambiguous_unit, + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Check temperature is not converted + state = hass.states.get(entity0.entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == normalized_unit + + +def test_ambiguous_units_of_measurement_aligned() -> None: + """Make sure all ambiguous UOM for sensor are also available for number.""" + + for ambiguous_unit, unit in SENSOR_AMBIGUOUS_UNITS.items(): + assert ambiguous_unit in NUMBER_AMBIGUOUS_UNITS + assert NUMBER_AMBIGUOUS_UNITS[ambiguous_unit] == unit + + @pytest.mark.parametrize("state_class", ["measurement", "total_increasing"]) async def test_deprecated_last_reset( hass: HomeAssistant, @@ -2958,7 +2997,6 @@ def test_device_class_units_are_complete() -> None: def test_device_class_converters_are_complete() -> None: """Test that the device class converters enum is complete.""" no_converter_device_classes = { - SensorDeviceClass.APPARENT_POWER, SensorDeviceClass.AQI, SensorDeviceClass.BATTERY, SensorDeviceClass.CO, @@ -2979,7 +3017,6 @@ def test_device_class_converters_are_complete() -> None: SensorDeviceClass.PM1, SensorDeviceClass.PM10, SensorDeviceClass.PM25, - SensorDeviceClass.REACTIVE_POWER, SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, SensorDeviceClass.SULPHUR_DIOXIDE, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 43f185f939a..8b6d55cb9a9 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -3755,6 +3755,44 @@ async def test_compile_hourly_statistics_convert_units_1( 30, ), (None, "m3", "m³", None, "volume", 13.050847, 13.333333, -10, 30), + (None, "\u00b5V", "\u03bcV", None, "voltage", 13.050847, 13.333333, -10, 30), + (None, "\u00b5Sv/h", "\u03bcSv/h", None, None, 13.050847, 13.333333, -10, 30), + ( + None, + "\u00b5S/cm", + "\u03bcS/cm", + None, + "conductivity", + 13.050847, + 13.333333, + -10, + 30, + ), + (None, "\u00b5g/ft³", "\u03bcg/ft³", None, None, 13.050847, 13.333333, -10, 30), + ( + None, + "\u00b5g/m³", + "\u03bcg/m³", + None, + "concentration", + 13.050847, + 13.333333, + -10, + 30, + ), + ( + None, + "\u00b5mol/s⋅m²", + "\u03bcmol/s⋅m²", + None, + None, + 13.050847, + 13.333333, + -10, + 30, + ), + (None, "\u00b5g", "\u03bcg", None, "mass", 13.050847, 13.333333, -10, 30), + (None, "\u00b5s", "\u03bcs", None, "duration", 13.050847, 13.333333, -10, 30), ], ) async def test_compile_hourly_statistics_equivalent_units_1( @@ -3884,6 +3922,17 @@ async def test_compile_hourly_statistics_equivalent_units_1( (None, "ft3", "ft³", None, 13.333333, -10, 30), (None, "ft³/m", "ft³/min", None, 13.333333, -10, 30), (None, "m3", "m³", None, 13.333333, -10, 30), + (None, "\u00b5V", "\u03bcV", None, 13.333333, -10, 30), + (SensorDeviceClass.VOLTAGE, "\u00b5V", "\u03bcV", None, 13.333333, -10, 30), + (None, "\u00b5Sv/h", "\u03bcSv/h", None, 13.333333, -10, 30), + (None, "\u00b5S/cm", "\u03bcS/cm", None, 13.333333, -10, 30), + (None, "\u00b5g/ft³", "\u03bcg/ft³", None, 13.333333, -10, 30), + (None, "\u00b5g/m³", "\u03bcg/m³", None, 13.333333, -10, 30), + (None, "\u00b5mol/s⋅m²", "\u03bcmol/s⋅m²", None, 13.333333, -10, 30), + (None, "\u00b5g", "\u03bcg", None, 13.333333, -10, 30), + (SensorDeviceClass.WEIGHT, "\u00b5g", "\u03bcg", None, 13.333333, -10, 30), + (None, "\u00b5s", "\u03bcs", None, 13.333333, -10, 30), + (SensorDeviceClass.DURATION, "\u00b5s", "\u03bcs", None, 13.333333, -10, 30), ], ) async def test_compile_hourly_statistics_equivalent_units_2( @@ -5705,6 +5754,14 @@ async def test_validate_statistics_unit_change_no_conversion( (NONE_SENSOR_ATTRIBUTES, "m3", "m³"), (NONE_SENSOR_ATTRIBUTES, "rpm", "RPM"), (NONE_SENSOR_ATTRIBUTES, "RPM", "rpm"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5V", "\u03bcV"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5Sv/h", "\u03bcSv/h"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5S/cm", "\u03bcS/cm"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5g/ft³", "\u03bcg/ft³"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5g/m³", "\u03bcg/m³"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5mol/s⋅m²", "\u03bcmol/s⋅m²"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5g", "\u03bcg"), + (NONE_SENSOR_ATTRIBUTES, "\u00b5s", "\u03bcs"), ], ) async def test_validate_statistics_unit_change_equivalent_units( @@ -5768,6 +5825,15 @@ async def test_validate_statistics_unit_change_equivalent_units( ("attributes", "unit1", "unit2", "supported_unit"), [ (NONE_SENSOR_ATTRIBUTES, "m³", "m3", "CCF, L, fl. oz., ft³, gal, mL, m³"), + (NONE_SENSOR_ATTRIBUTES, "\u03bcV", "\u00b5V", "MV, V, kV, mV, \u03bcV"), + (NONE_SENSOR_ATTRIBUTES, "\u03bcS/cm", "\u00b5S/cm", "S/cm, mS/cm, \u03bcS/cm"), + ( + NONE_SENSOR_ATTRIBUTES, + "\u03bcg", + "\u00b5g", + "g, kg, lb, mg, oz, st, \u03bcg", + ), + (NONE_SENSOR_ATTRIBUTES, "\u03bcs", "\u00b5s", "d, h, min, ms, s, w, \u03bcs"), ], ) async def test_validate_statistics_unit_change_equivalent_units_2( diff --git a/tests/components/sensorpro/__init__.py b/tests/components/sensorpro/__init__.py index a63bdbe08dc..7f2a7b1f33e 100644 --- a/tests/components/sensorpro/__init__.py +++ b/tests/components/sensorpro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/sensorpush/__init__.py b/tests/components/sensorpush/__init__.py index 88fb2072961..6f1f80d777e 100644 --- a/tests/components/sensorpush/__init__.py +++ b/tests/components/sensorpush/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 0ee34eebf3f..f046f95ed42 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -18,7 +18,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), @@ -151,7 +149,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -161,7 +158,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index 39dd9e512ae..5d5c6d0edba 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -18,7 +18,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index cd762a4b2ea..0440505859a 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -18,7 +18,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 4eccb075b67..47ff723bddc 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -548,6 +548,7 @@ def _mock_rpc_device(version: str | None = None): ), xmod_info={}, zigbee_enabled=False, + zigbee_firmware=False, ip_address="10.10.10.10", ) type(device).name = PropertyMock(return_value="Test name") diff --git a/tests/components/shelly/fixtures/pro_3em.json b/tests/components/shelly/fixtures/pro_3em.json index 93351e9bc65..4895766cc49 100644 --- a/tests/components/shelly/fixtures/pro_3em.json +++ b/tests/components/shelly/fixtures/pro_3em.json @@ -151,7 +151,7 @@ "c_pf": 0.72, "c_voltage": 230.2, "id": 0, - "n_current": null, + "n_current": 3.124, "total_act_power": 2413.825, "total_aprt_power": 2525.779, "total_current": 11.116, diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 0b8ec71771b..9dcda321057 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -4303,6 +4303,62 @@ 'state': '230.2', }) # --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_phase_n_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase N current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-n_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase N current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_n_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.124', + }) +# --- # name: test_shelly_pro_3em[sensor.test_name_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 93893035a3e..3282756fe28 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -870,17 +870,17 @@ async def test_options_flow_abort_no_scripts_support( assert result["reason"] == "no_scripts_support" -async def test_options_flow_abort_zigbee_enabled( +async def test_options_flow_abort_zigbee_firmware( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: - """Test ble options abort if Zigbee is enabled for the device.""" - monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", True) + """Test ble options abort if Zigbee firmware is active.""" + monkeypatch.setattr(mock_rpc_device, "zigbee_firmware", True) entry = await init_integration(hass, 4) result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "zigbee_enabled" + assert result["reason"] == "zigbee_firmware" async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 5b4372fe938..ff61eda626f 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -864,7 +864,7 @@ async def test_rpc_update_entry_fw_ver( @pytest.mark.parametrize( - ("supports_scripts", "zigbee_enabled", "result"), + ("supports_scripts", "zigbee_firmware", "result"), [ (True, False, True), (True, True, False), @@ -877,14 +877,14 @@ async def test_rpc_runs_connected_events_when_initialized( mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, supports_scripts: bool, - zigbee_enabled: bool, + zigbee_firmware: bool, result: bool, ) -> None: """Test RPC runs connected events when initialized.""" monkeypatch.setattr( mock_rpc_device, "supports_scripts", AsyncMock(return_value=supports_scripts) ) - monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", zigbee_enabled) + monkeypatch.setattr(mock_rpc_device, "zigbee_firmware", zigbee_firmware) monkeypatch.setattr(mock_rpc_device, "initialized", False) await init_integration(hass, 2) diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index f68d2f82f1b..8dfd59c49ba 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -9,6 +9,7 @@ from homeassistant.components.shelly.const import ( BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from homeassistant.core import HomeAssistant @@ -129,3 +130,84 @@ async def test_unsupported_firmware_issue_exc( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test repair issues handling for the outbound WebSocket incorrectly enabled.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.ws_setconfig.call_count == 1 + assert mock_rpc_device.ws_setconfig.call_args[0] == (False, ws_url) + assert mock_rpc_device.trigger_reboot.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_outbound_websocket_incorrectly_enabled_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, +) -> None: + """Test repair issues handling when ws_setconfig ends with an exception.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + mock_rpc_device.ws_setconfig.side_effect = exception + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_rpc_device.ws_setconfig.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 54923b538f6..f1866d83e2a 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,15 +1,18 @@ """Tests for Shelly switch platform.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, Mock from aioshelly.const import MODEL_1PM, MODEL_GAS, MODEL_MOTION from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.shelly.const import ( DOMAIN, + ENTRY_RELOAD_COOLDOWN, MODEL_WALL_DISPLAY, MOTION_MODELS, ) @@ -28,10 +31,17 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, register_device, register_entity +from . import ( + init_integration, + inject_rpc_device_event, + register_device, + register_entity, +) -from tests.common import mock_restore_cache +from tests.common import async_fire_time_changed, mock_restore_cache +DEVICE_BLOCK_ID = 4 +LIGHT_BLOCK_ID = 2 RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 MOTION_BLOCK_ID = 3 @@ -318,14 +328,51 @@ async def test_block_device_mode_roller( async def test_block_device_app_type_light( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device in app type set to light mode.""" + switch_entity_id = "switch.test_name_channel_1" + light_entity_id = "light.test_name_channel_1" + + # Remove light blocks to prevent light entity creation + monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "sensor") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "gain") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "brightness") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "effect") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "colorTemp") + + await init_integration(hass, 1) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) + mock_block_device.mock_update() + monkeypatch.setitem( mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" ) - await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1") is None + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 2) + mock_block_device.mock_update() + await hass.async_block_till_done() + + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) async def test_rpc_device_services( @@ -374,15 +421,57 @@ async def test_rpc_device_unique_ids( async def test_rpc_device_switch_type_lights_mode( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device with switch in consumption type lights mode.""" + switch_entity_id = "switch.test_name_test_switch_0" + light_entity_id = "light.test_name_test_switch_0" + + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + await init_integration(hass, 2) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light monkeypatch.setitem( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) - await init_integration(hass, 2) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "data": [], + "event": "config_changed", + "id": 1, + "ts": 1668522399.2, + }, + { + "data": [], + "id": 2, + "ts": 1668522399.2, + }, + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is None + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) @pytest.mark.parametrize( diff --git a/tests/components/sleep_as_android/__init__.py b/tests/components/sleep_as_android/__init__.py new file mode 100644 index 00000000000..3b970b011e7 --- /dev/null +++ b/tests/components/sleep_as_android/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sleep as Android integration.""" diff --git a/tests/components/sleep_as_android/conftest.py b/tests/components/sleep_as_android/conftest.py new file mode 100644 index 00000000000..97cc6da16a0 --- /dev/null +++ b/tests/components/sleep_as_android/conftest.py @@ -0,0 +1,34 @@ +"""Common fixtures for the Sleep as Android tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.sleep_as_android.const import DOMAIN +from homeassistant.const import CONF_WEBHOOK_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sleep_as_android.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Sleep as Android configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sleep as Android", + data={ + "cloudhook": False, + CONF_WEBHOOK_ID: "webhook_id", + }, + entry_id="01JRD840SAZ55DGXBD78PTQ4EF", + ) diff --git a/tests/components/sleep_as_android/snapshots/test_diagnostics.ambr b/tests/components/sleep_as_android/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c7e391317da --- /dev/null +++ b/tests/components/sleep_as_android/snapshots/test_diagnostics.ambr @@ -0,0 +1,8 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'cloudhook': False, + }), + }) +# --- diff --git a/tests/components/sleep_as_android/snapshots/test_event.ambr b/tests/components/sleep_as_android/snapshots/test_event.ambr new file mode 100644 index 00000000000..27e789351a3 --- /dev/null +++ b/tests/components/sleep_as_android/snapshots/test_event.ambr @@ -0,0 +1,494 @@ +# serializer version: 1 +# name: test_setup[event.sleep_as_android_alarm_clock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'alert_dismiss', + 'alert_start', + 'rescheduled', + 'skip_next', + 'snooze_canceled', + 'snooze_clicked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_alarm_clock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm clock', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_alarm_clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_alarm_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'alert_dismiss', + 'alert_start', + 'rescheduled', + 'skip_next', + 'snooze_canceled', + 'snooze_clicked', + ]), + 'friendly_name': 'Sleep as Android Alarm clock', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_alarm_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_lullaby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'start', + 'stop', + 'volume_down', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_lullaby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lullaby', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_lullaby', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_lullaby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'start', + 'stop', + 'volume_down', + ]), + 'friendly_name': 'Sleep as Android Lullaby', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_lullaby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'antisnoring', + 'apnea_alarm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep health', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_health', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'antisnoring', + 'apnea_alarm', + ]), + 'friendly_name': 'Sleep as Android Sleep health', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'awake', + 'deep_sleep', + 'light_sleep', + 'not_awake', + 'rem', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep phase', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'awake', + 'deep_sleep', + 'light_sleep', + 'not_awake', + 'rem', + ]), + 'friendly_name': 'Sleep as Android Sleep phase', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'paused', + 'resumed', + 'started', + 'stopped', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sleep tracking', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'paused', + 'resumed', + 'started', + 'stopped', + ]), + 'friendly_name': 'Sleep as Android Sleep tracking', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_smart_wake_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'before_smart_period', + 'smart_period', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_smart_wake_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart wake-up', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_smart_wakeup', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_smart_wake_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'before_smart_period', + 'smart_period', + ]), + 'friendly_name': 'Sleep as Android Smart wake-up', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_smart_wake_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sound_recognition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'baby', + 'cough', + 'laugh', + 'snore', + 'talk', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sound_recognition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound recognition', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sound_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sound_recognition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'baby', + 'cough', + 'laugh', + 'snore', + 'talk', + ]), + 'friendly_name': 'Sleep as Android Sound recognition', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sound_recognition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_user_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'wake_up_check', + 'show_skip_next_alarm', + 'time_to_bed_alarm_alert', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_user_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'User notification', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_user_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_user_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'wake_up_check', + 'show_skip_next_alarm', + 'time_to_bed_alarm_alert', + ]), + 'friendly_name': 'Sleep as Android User notification', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_user_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sleep_as_android/snapshots/test_sensor.ambr b/tests/components/sleep_as_android/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fb7f7554689 --- /dev/null +++ b/tests/components/sleep_as_android/snapshots/test_sensor.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_setup[sensor.sleep_as_android_alarm_label-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sleep_as_android_alarm_label', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm label', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_alarm_label', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.sleep_as_android_alarm_label-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sleep as Android Alarm label', + }), + 'context': , + 'entity_id': 'sensor.sleep_as_android_alarm_label', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'label', + }) +# --- +# name: test_setup[sensor.sleep_as_android_next_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sleep_as_android_next_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next alarm', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_next_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.sleep_as_android_next_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Sleep as Android Next alarm', + }), + 'context': , + 'entity_id': 'sensor.sleep_as_android_next_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-26T12:21:00+00:00', + }) +# --- diff --git a/tests/components/sleep_as_android/test_config_flow.py b/tests/components/sleep_as_android/test_config_flow.py new file mode 100644 index 00000000000..1642263d0ed --- /dev/null +++ b/tests/components/sleep_as_android/test_config_flow.py @@ -0,0 +1,38 @@ +"""Test the Sleep as Android config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components.sleep_as_android.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.webhook.async_generate_id", + return_value="webhook_id", + ), + patch( + "homeassistant.components.webhook.async_generate_url", + return_value="http://example.com:8123", + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sleep as Android" + assert result["data"] == { + "cloudhook": False, + CONF_WEBHOOK_ID: "webhook_id", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sleep_as_android/test_diagnostics.py b/tests/components/sleep_as_android/test_diagnostics.py new file mode 100644 index 00000000000..a3e67dafe76 --- /dev/null +++ b/tests/components/sleep_as_android/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for Sleep as Android diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/sleep_as_android/test_event.py b/tests/components/sleep_as_android/test_event.py new file mode 100644 index 00000000000..4e3a94f919b --- /dev/null +++ b/tests/components/sleep_as_android/test_event.py @@ -0,0 +1,173 @@ +"""Test the Sleep as Android event platform.""" + +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import patch + +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def event_only() -> Generator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.sleep_as_android.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@freeze_time("2025-01-01T03:30:00.000Z") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of event platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity", "payload"), + [ + ("sleep_tracking", {"event": "sleep_tracking_paused"}), + ("sleep_tracking", {"event": "sleep_tracking_resumed"}), + ("sleep_tracking", {"event": "sleep_tracking_started"}), + ("sleep_tracking", {"event": "sleep_tracking_stopped"}), + ( + "alarm_clock", + { + "event": "alarm_alert_dismiss", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "alarm_clock", + { + "event": "alarm_alert_start", + "value1": "1582719660934", + "value2": "label", + }, + ), + ("alarm_clock", {"event": "alarm_rescheduled"}), + ( + "alarm_clock", + {"event": "alarm_skip_next", "value1": "1582719660934", "value2": "label"}, + ), + ( + "alarm_clock", + { + "event": "alarm_snooze_canceled", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "alarm_clock", + { + "event": "alarm_snooze_clicked", + "value1": "1582719660934", + "value2": "label", + }, + ), + ("smart_wake_up", {"event": "before_smart_period", "value1": "label"}), + ("smart_wake_up", {"event": "smart_period"}), + ("sleep_health", {"event": "antisnoring"}), + ("sleep_health", {"event": "apnea_alarm"}), + ("lullaby", {"event": "lullaby_start"}), + ("lullaby", {"event": "lullaby_stop"}), + ("lullaby", {"event": "lullaby_volume_down"}), + ("sleep_phase", {"event": "awake"}), + ("sleep_phase", {"event": "deep_sleep"}), + ("sleep_phase", {"event": "light_sleep"}), + ("sleep_phase", {"event": "not_awake"}), + ("sleep_phase", {"event": "rem"}), + ("sound_recognition", {"event": "sound_event_baby"}), + ("sound_recognition", {"event": "sound_event_cough"}), + ("sound_recognition", {"event": "sound_event_laugh"}), + ("sound_recognition", {"event": "sound_event_snore"}), + ("sound_recognition", {"event": "sound_event_talk"}), + ("user_notification", {"event": "alarm_wake_up_check"}), + ( + "user_notification", + { + "event": "show_skip_next_alarm", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "user_notification", + { + "event": "time_to_bed_alarm_alert", + "value1": "1582719660934", + "value2": "label", + }, + ), + ], +) +@freeze_time("2025-01-01T03:30:00.000+00:00") +async def test_webhook_event( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + entity: str, + payload: dict[str, str], +) -> None: + """Test webhook events.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get(f"event.sleep_as_android_{entity}")) + assert state.state == STATE_UNKNOWN + + client = await hass_client_no_auth() + + response = await client.post("/api/webhook/webhook_id", json=payload) + assert response.status == HTTPStatus.NO_CONTENT + + assert (state := hass.states.get(f"event.sleep_as_android_{entity}")) + assert state.state == "2025-01-01T03:30:00.000+00:00" + + +async def test_webhook_invalid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test webhook event call with invalid data.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client_no_auth() + + response = await client.post("/api/webhook/webhook_id", json={}) + + assert response.status == HTTPStatus.UNPROCESSABLE_ENTITY diff --git a/tests/components/sleep_as_android/test_init.py b/tests/components/sleep_as_android/test_init.py new file mode 100644 index 00000000000..27177a5a5ad --- /dev/null +++ b/tests/components/sleep_as_android/test_init.py @@ -0,0 +1,22 @@ +"""Test the Sleep as Android integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/sleep_as_android/test_sensor.py b/tests/components/sleep_as_android/test_sensor.py new file mode 100644 index 00000000000..760df1e0181 --- /dev/null +++ b/tests/components/sleep_as_android/test_sensor.py @@ -0,0 +1,124 @@ +"""Test the Sleep as Android sensor platform.""" + +from collections.abc import Generator +from datetime import datetime +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + mock_restore_cache_with_extra_data, + snapshot_platform, +) +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.sleep_as_android.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "sensor.sleep_as_android_next_alarm", + "", + ), + { + "native_value": datetime.fromisoformat("2020-02-26T12:21:00+00:00"), + "native_unit_of_measurement": None, + }, + ), + ( + State( + "sensor.sleep_as_android_alarm_label", + "", + ), + { + "native_value": "label", + "native_unit_of_measurement": None, + }, + ), + ), + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + "event", + [ + "alarm_snooze_clicked", + "alarm_snooze_canceled", + "alarm_alert_start", + "alarm_alert_dismiss", + "alarm_skip_next", + "show_skip_next_alarm", + "alarm_rescheduled", + ], +) +async def test_webhook_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + event: str, +) -> None: + """Test webhook updates sensor.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("sensor.sleep_as_android_next_alarm")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == STATE_UNKNOWN + + client = await hass_client_no_auth() + + response = await client.post( + "/api/webhook/webhook_id", + json={ + "event": event, + "value1": "1582719660934", + "value2": "label", + }, + ) + assert response.status == HTTPStatus.NO_CONTENT + + assert (state := hass.states.get("sensor.sleep_as_android_next_alarm")) + assert state.state == "2020-02-26T12:21:00+00:00" + + assert (state := hass.states.get("sensor.sleep_as_android_alarm_label")) + assert state.state == "label" diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index a9456bd3cc6..f52f489aec3 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -7,10 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( BED_PRESETS, + CoreTemps, FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, @@ -29,6 +31,7 @@ from tests.common import MockConfigEntry BED_ID = "123456" BED_NAME = "Test Bed" BED_NAME_LOWER = BED_NAME.lower().replace(" ", "_") +CORE_CLIMATE_TIME = 240 SLEEPER_L_ID = "98765" SLEEPER_R_ID = "43219" SLEEPER_L_NAME = "SleeperL" @@ -91,6 +94,7 @@ def mock_bed() -> MagicMock: bed.foundation.lights = [light_1, light_2] bed.foundation.foot_warmers = [] + bed.foundation.core_climates = [] return bed @@ -127,6 +131,7 @@ def mock_asyncsleepiq_single_foundation( preset.options = BED_PRESETS mock_bed.foundation.foot_warmers = [] + mock_bed.foundation.core_climates = [] yield client @@ -185,6 +190,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock]: foot_warmer_r.timer = FOOT_WARM_TIME foot_warmer_r.temperature = FootWarmingTemps.OFF + core_climate_l = create_autospec(SleepIQCoreClimate) + core_climate_r = create_autospec(SleepIQCoreClimate) + mock_bed.foundation.core_climates = [core_climate_l, core_climate_r] + + core_climate_l.side = Side.LEFT + core_climate_l.timer = CORE_CLIMATE_TIME + core_climate_l.temperature = CoreTemps.COOLING_PULL_MED + + core_climate_r.side = Side.RIGHT + core_climate_r.timer = CORE_CLIMATE_TIME + core_climate_r.temperature = CoreTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index f0739aabc9d..dd45cdc2400 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -198,3 +198,42 @@ async def test_foot_warmer_timer( await hass.async_block_till_done() assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 + + +async def test_core_climate_timer( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: + """Test the SleepIQ core climate number values for a bed with two sides.""" + entry = await setup_platform(hass, NUMBER_DOMAIN) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert state.state == "240.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MAX) == 600 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_core_climate_timer" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer", + ATTR_VALUE: 420, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[0].timer == 420 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index bbfb612e9cb..17d57eba7d3 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from asyncsleepiq import FootWarmingTemps +from asyncsleepiq import CoreTemps, FootWarmingTemps from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, @@ -21,6 +21,7 @@ from .conftest import ( BED_ID, BED_NAME, BED_NAME_LOWER, + CORE_CLIMATE_TIME, FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, @@ -204,3 +205,77 @@ async def test_foot_warmer( mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ 1 ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) + + +async def test_core_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test the SleepIQ select entity for core climate.""" + entry = await setup_platform(hass, SELECT_DOMAIN) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert state.state == "cooling_medium" + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert state.state == CoreTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate", + ATTR_OPTION: "heating_high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_with(CoreTemps.HEATING_PUSH_HIGH, CORE_CLIMATE_TIME) diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index 5b1a9f5ce2f..cc93a49b98a 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'id': , 'identifiers': set({ }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Innovation in Motion', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890ab', - 'suggested_area': None, 'sw_version': 2, 'via_device_id': None, }) diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index c8939ef2d64..b2e488318a5 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -11,8 +11,10 @@ import pytest from homeassistant.components.sma.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( @@ -28,13 +30,19 @@ from tests.conftest import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="SMA123456", - macaddress="0015BB00abcd", + macaddress="0015bb00abcd", ) DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( ip="1.1.1.1", hostname="SMA123456789", - macaddress="0015BB00abcd", + macaddress="0015bb00abcd", +) + +DHCP_DISCOVERY_DUPLICATE_001 = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789-001", + macaddress="0015bb00abcd", ) @@ -154,6 +162,31 @@ async def test_dhcp_already_configured( assert result["reason"] == "already_configured" +async def test_dhcp_already_configured_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by DHCP when already configured and MAC is added.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert CONF_MAC not in mock_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY_DUPLICATE_001, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert mock_config_entry.data.get(CONF_MAC) == format_mac( + DHCP_DISCOVERY_DUPLICATE_001.macaddress + ) + + @pytest.mark.parametrize( ("exception", "error"), [ diff --git a/tests/components/smarla/conftest.py b/tests/components/smarla/conftest.py index d472e929bcc..d25dab2446f 100644 --- a/tests/components/smarla/conftest.py +++ b/tests/components/smarla/conftest.py @@ -73,8 +73,20 @@ def mock_federwiege(mock_connection: MagicMock) -> Generator[MagicMock]: mock_babywiege_service.props["smart_mode"].get.return_value = False mock_babywiege_service.props["intensity"].get.return_value = 1 + mock_analyser_service = MagicMock(spec=Service) + mock_analyser_service.props = { + "oscillation": MagicMock(spec=Property), + "activity": MagicMock(spec=Property), + "swing_count": MagicMock(spec=Property), + } + + mock_analyser_service.props["oscillation"].get.return_value = [0, 0] + mock_analyser_service.props["activity"].get.return_value = 0 + mock_analyser_service.props["swing_count"].get.return_value = 0 + federwiege.services = { "babywiege": mock_babywiege_service, + "analyser": mock_analyser_service, } federwiege.get_property = MagicMock( diff --git a/tests/components/smarla/snapshots/test_sensor.ambr b/tests/components/smarla/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..88d6a6ecea6 --- /dev/null +++ b/tests/components/smarla/snapshots/test_sensor.ambr @@ -0,0 +1,208 @@ +# serializer version: 1 +# name: test_entities[sensor.smarla_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smarla_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activity', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'activity', + 'unique_id': 'ABCD-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.smarla_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Activity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smarla_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_amplitude-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smarla_amplitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Amplitude', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'amplitude', + 'unique_id': 'ABCD-amplitude', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smarla_amplitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Amplitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smarla_amplitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_period-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smarla_period', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Period', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'period', + 'unique_id': 'ABCD-period', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smarla_period-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Period', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smarla_period', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_swing_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smarla_swing_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Swing count', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'swing_count', + 'unique_id': 'ABCD-swing_count', + 'unit_of_measurement': 'swings', + }) +# --- +# name: test_entities[sensor.smarla_swing_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Swing count', + 'state_class': , + 'unit_of_measurement': 'swings', + }), + 'context': , + 'entity_id': 'sensor.smarla_swing_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/smarla/test_config_flow.py b/tests/components/smarla/test_config_flow.py index a2bd5b36fc0..beccf6e4b95 100644 --- a/tests/components/smarla/test_config_flow.py +++ b/tests/components/smarla/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from homeassistant.components.smarla.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -12,9 +14,8 @@ from .const import MOCK_SERIAL_NUMBER, MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_config_flow( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_config_flow(hass: HomeAssistant) -> None: """Test creating a config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -35,9 +36,8 @@ async def test_config_flow( assert result["result"].unique_id == MOCK_SERIAL_NUMBER -async def test_malformed_token( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_malformed_token(hass: HomeAssistant) -> None: """Test we show user form on malformed token input.""" with patch( "homeassistant.components.smarla.config_flow.Connection", side_effect=ValueError @@ -60,9 +60,8 @@ async def test_malformed_token( assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_invalid_auth( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_invalid_auth(hass: HomeAssistant, mock_connection: MagicMock) -> None: """Test we show user form on invalid auth.""" with patch.object( mock_connection, "refresh_token", new=AsyncMock(return_value=False) @@ -85,8 +84,9 @@ async def test_invalid_auth( assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") async def test_device_exists_abort( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test we abort config flow if Smarla device already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/smarla/test_init.py b/tests/components/smarla/test_init.py index b9d291f582d..9523772d914 100644 --- a/tests/components/smarla/test_init.py +++ b/tests/components/smarla/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -10,6 +12,7 @@ from . import setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_federwiege") async def test_init_invalid_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock ) -> None: diff --git a/tests/components/smarla/test_sensor.py b/tests/components/smarla/test_sensor.py new file mode 100644 index 00000000000..196e6d2a6f0 --- /dev/null +++ b/tests/components/smarla/test_sensor.py @@ -0,0 +1,85 @@ +"""Test sensor platform for Swing2Sleep Smarla integration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, update_property_listeners + +from tests.common import MockConfigEntry, snapshot_platform + +SENSOR_ENTITIES = [ + { + "entity_id": "sensor.smarla_amplitude", + "service": "analyser", + "property": "oscillation", + "test_value": [1, 0], + }, + { + "entity_id": "sensor.smarla_period", + "service": "analyser", + "property": "oscillation", + "test_value": [0, 1], + }, + { + "entity_id": "sensor.smarla_activity", + "service": "analyser", + "property": "activity", + "test_value": 1, + }, + { + "entity_id": "sensor.smarla_swing_count", + "service": "analyser", + "property": "swing_count", + "test_value": 1, + }, +] + + +@pytest.mark.usefixtures("mock_federwiege") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Smarla entities.""" + with ( + patch("homeassistant.components.smarla.PLATFORMS", [Platform.SENSOR]), + ): + assert await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize("entity_info", SENSOR_ENTITIES) +async def test_sensor_state_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, + entity_info: dict[str, str], +) -> None: + """Test Smarla Sensor callback.""" + assert await setup_integration(hass, mock_config_entry) + + mock_sensor_property = mock_federwiege.get_property( + entity_info["service"], entity_info["property"] + ) + + entity_id = entity_info["entity_id"] + + assert hass.states.get(entity_id).state == "0" + + mock_sensor_property.get.return_value = entity_info["test_value"] + + await update_property_listeners(mock_sensor_property) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "1" diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index e8cde67122b..f13617d64d5 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -96,6 +96,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ + "aq_sensor_3_ikea", "da_ac_airsensor_01001", "da_ac_rac_000001", "da_ac_rac_000003", @@ -112,6 +113,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "centralite", "da_ref_normal_000001", "da_ref_normal_01011", + "da_ref_normal_01011_onedoor", "da_ref_normal_01001", "vd_network_audio_002s", "vd_network_audio_003s", @@ -130,6 +132,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wm_000001_1", "da_wm_sc_000001", "da_rvc_normal_000001", + "da_rvc_map_01011", "da_ks_microwave_0101x", "da_ks_cooktop_31001", "da_ks_range_0101x", diff --git a/tests/components/smartthings/fixtures/device_status/aq_sensor_3_ikea.json b/tests/components/smartthings/fixtures/device_status/aq_sensor_3_ikea.json new file mode 100644 index 00000000000..383c5d1e85e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aq_sensor_3_ikea.json @@ -0,0 +1,94 @@ +{ + "components": { + "main": { + "tvocMeasurement": { + "tvocLevel": { + "value": 0.1, + "unit": "ppm", + "timestamp": "2025-08-15T13:48:52.222Z" + } + }, + "fineDustSensor": { + "fineDustLevel": { + "value": 1, + "unit": "\u03bcg/m^3", + "timestamp": "2025-08-15T13:29:36.938Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 53.0, + "unit": "%", + "timestamp": "2025-08-15T13:48:42.554Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "minimum": -10.0, + "maximum": 50.0 + }, + "unit": "C", + "timestamp": "2025-03-19T19:48:47.896Z" + }, + "temperature": { + "value": 22.0, + "unit": "C", + "timestamp": "2025-08-15T12:25:37.127Z" + } + }, + "refresh": {}, + "airQualityHealthConcern": { + "supportedAirQualityValues": { + "value": null + }, + "airQualityHealthConcern": { + "value": "good", + "timestamp": "2025-08-15T13:17:38.791Z" + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": "TOO_MANY_CLIENTS", + "timestamp": "2025-06-09T05:59:52.076Z" + }, + "imageTransferProgress": { + "value": null + }, + "availableVersion": { + "value": "00010010", + "timestamp": "2025-03-19T19:49:07.016Z" + }, + "lastUpdateStatus": { + "value": "updateFailed", + "timestamp": "2025-06-09T05:59:52.072Z" + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-06-09T05:59:52.105Z" + }, + "estimatedTimeRemaining": { + "value": null + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-03-19T19:49:07.014Z" + }, + "currentVersion": { + "value": "00010010", + "timestamp": "2025-06-09T05:59:52.071Z" + }, + "lastUpdateTime": { + "value": "2025-06-09T05:59:51Z", + "timestamp": "2025-06-09T05:59:52.076Z" + }, + "supportsProgressReports": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json new file mode 100644 index 00000000000..5cb33eb9535 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json @@ -0,0 +1,1380 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-08-14T05:31:51.945Z" + } + }, + "samsungce.fridgeWelcomeLighting": { + "detectionProximity": { + "value": null + }, + "supportedDetectionProximities": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": null + }, + "contents": { + "value": null + }, + "lastUpdatedTime": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "00130445", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "00090026001610304100000021010000", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "description": { + "value": "TP1X_REF_21K", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_REF_21K", + "timestamp": "2025-08-14T03:17:25.761Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-08-12T13:08:24.409Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP1-24-T4-COM_20250706", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "di": { + "value": "271d82e9-5b0c-e4b8-058e-cdf23a188610", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "n": { + "value": "Samsung-Refrigerator", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnmo": { + "value": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01011", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnpv": { + "value": "SYSTEM 2.0", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "pi": { + "value": "271d82e9-5b0c-e4b8-058e-cdf23a188610", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-08-12T13:21:14.953Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": "off", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.driverState": { + "driverState": { + "value": { + "device/0": [ + { + "rt": ["x.com.samsung.devcol", "oic.wk.col"], + "if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"] + }, + { + "href": "/alarms/vs/0", + "rep": { + "href": "/alarms/vs/0" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On", + "href": "/bespoke/vs/0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "", + "href": "/configuration/vs/0" + } + }, + { + "href": "/door/onedoorfreezer/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close", + "href": "/door/onedoorfreezer/vs/0" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Door" + } + ], + "href": "/doors/vs/0" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "1129", + "x.com.samsung.da.start": "2025-08-13T12:34:56Z", + "x.com.samsung.da.realSaving": "On", + "href": "/drlc/vs/0" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "263", + "x.com.samsung.da.instantaneousPower": "1", + "x.com.samsung.da.cumulativePower": "801", + "x.com.samsung.da.cumulativeSavedPower": "30", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "href": "/energy/consumption/vs/0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00", + "x.com.samsung.supprtedtype": 1, + "href": "/file/information/vs/0" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "href": "/refrigeration/vs/0" + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": ["1"], + "href": "/energy/ailevel/vs/0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "08174EAY700355P", + "x.com.samsung.da.otnDUID": "EXCCN6NY7KZ4W", + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RO0", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "250706", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2408090D, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "href": "/information/vs/0" + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.childlock": "Locked", + "href": "/status/lock/vs/0" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": ["RVACATION_OFF"], + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ], + "href": "/mode/vs/0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true", + "href": "/realtimenotiforclient/vs/0" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0, + "href": "/runningmode/vs/0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": ["Start"], + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": ["ErrorCode_None"], + "href": "/selfcheck/vs/0" + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "temperature": 3.0, + "range": [1.0, 7.0], + "units": "C", + "href": "/temperature/desired/cooler/0" + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "temperature": 3.0, + "range": [1.0, 7.0], + "units": "C", + "href": "/temperature/current/cooler/0" + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "3", + "x.com.samsung.da.current": "3", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ], + "href": "/temperatures/vs/0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Refrigerator", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-24-T4-COM", + "versions": ["20250706"], + "visVersion": "250706" + }, + { + "type": "Micom", + "modelId": "028100130445FFFFFFFF", + "versions": ["2408090D", "FFFFFFFF"], + "visVersion": "240809" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Warsaw", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": ["helper_hotspot", "ble_ocf"], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": ["2.4G"], + "calmConnectionCare": { + "version": "1.0", + "role": ["things"] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "34:FC:99:0A:67:55", + "macaddressBLE": "34:FC:99:0A:67:56" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] + }, + "timestamp": "2025-08-14T03:17:25.764Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "custom.waterFilter", + "custom.dustFilter", + "samsungce.viewInside", + "samsungce.fridgeWelcomeLighting", + "sec.smartthingsHub", + "samsungce.powerFreeze", + "samsungce.sabbathMode" + ], + "timestamp": "2025-08-13T17:29:03.375Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25060101, + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "RO0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker", + "icemaker-02", + "icemaker-03", + "pantry-01", + "pantry-02", + "specialzone-01", + "scale-10", + "scale-11", + "cooler", + "freezer", + "cvroom" + ], + "timestamp": "2025-08-12T12:36:05.791Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 2, + "start": "2025-08-14T07:24:20Z", + "duration": 1441, + "override": false + }, + "timestamp": "2025-08-14T07:24:23.569Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 861, + "deltaEnergy": 0, + "power": 1, + "powerEnergy": 0.27936416665712993, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 35, + "start": "2025-08-14T07:04:50Z", + "end": "2025-08-14T07:21:35Z" + }, + "timestamp": "2025-08-14T07:21:35.717Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": "passed", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "250706", + "description": "WiFi Module" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "2408090D, FFFFFFFF", + "description": "Micom" + } + ], + "timestamp": "2025-08-12T13:21:17.127Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null + }, + "dustFilterUsage": { + "value": null + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": null + }, + "dustFilterCapacity": { + "value": null + }, + "dustFilterResetType": { + "value": null + } + }, + "refrigeration": { + "defrost": { + "value": null + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-08-12T14:27:24.223Z" + }, + "rapidFreezing": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-08-12T14:27:24.223Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-08-13T12:50:26.756Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-08-12T12:36:06.104Z" + }, + "energySavingLevel": { + "value": 1, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": [1], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingOperation": { + "value": true, + "timestamp": "2025-08-14T07:24:28.808Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-08-12T13:14:52.642Z" + }, + "otnDUID": { + "value": "EXCCN6NY7KZ4W", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-08-12T13:14:52.642Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-08-12T13:08:24.243Z" + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": null + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "specialzone-01": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + } + }, + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-08-14T05:31:51.945Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2025-08-12T12:36:06.177Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-13T19:45:46.907Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + }, + "coolingSetpoint": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-13T19:43:49.744Z" + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.temperatureSetting", "custom.fridgeMode"], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 21, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "coolingSetpoint": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.freezerConvertMode"], + "timestamp": "2025-08-12T12:42:30.879Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json new file mode 100644 index 00000000000..686207f67d2 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json @@ -0,0 +1,994 @@ +{ + "components": { + "refill-drainage-kit": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "samsungce.activationState": { + "activationState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.drainFilter", + "samsungce.connectionState", + "samsungce.activationState" + ], + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "samsungce.drainFilter": { + "drainFilterUsageStep": { + "value": null + }, + "drainFilterStatus": { + "value": null + }, + "drainFilterLastResetDate": { + "value": null + }, + "drainFilterResetType": { + "value": null + }, + "drainFilterUsage": { + "value": null + } + } + }, + "station": { + "custom.hepaFilter": { + "hepaFilterCapacity": { + "value": null + }, + "hepaFilterStatus": { + "value": "normal", + "timestamp": "2025-07-02T04:35:14.449Z" + }, + "hepaFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-07-02T04:35:14.449Z" + }, + "hepaFilterUsageStep": { + "value": null + }, + "hepaFilterUsage": { + "value": null + }, + "hepaFilterLastResetDate": { + "value": null + } + }, + "samsungce.robotCleanerDustBag": { + "supportedStatus": { + "value": ["full", "normal"], + "timestamp": "2025-07-02T04:35:14.620Z" + }, + "status": { + "value": "normal", + "timestamp": "2025-07-02T04:35:14.620Z" + } + } + }, + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": null + }, + "playbackStatus": { + "value": null + } + }, + "robotCleanerTurboMode": { + "robotCleanerTurboMode": { + "value": "extraSilence", + "timestamp": "2025-07-10T11:00:38.909Z" + } + }, + "ocf": { + "st": { + "value": "2024-01-01T09:00:15Z", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnfv": { + "value": "20250123.105306", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "di": { + "value": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "n": { + "value": "[robot vacuum] Samsung", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnmo": { + "value": "JETBOT_COMBO_9X00_24K|50029141|80010b0002d8411f0100000000000000", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "vid": { + "value": "DA-RVC-MAP-01011", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnpv": { + "value": "1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "pi": { + "value": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.robotCleanerAudioClip", + "custom.hepaFilter", + "imageCapture", + "mediaPlaybackRepeat", + "mediaPlayback", + "mediaTrackControl", + "samsungce.robotCleanerPatrol", + "samsungce.musicPlaylist", + "audioVolume", + "audioMute", + "videoCapture", + "samsungce.robotCleanerWelcome", + "samsungce.microphoneSettings", + "samsungce.robotCleanerGuidedPatrol", + "samsungce.robotCleanerSafetyPatrol", + "soundDetection", + "samsungce.soundDetectionSensitivity", + "audioTrackAddressing", + "samsungce.robotCleanerMonitoringAutomation" + ], + "timestamp": "2025-06-20T14:12:58.125Z" + } + }, + "logTrigger": { + "logState": { + "value": "idle", + "timestamp": "2025-07-02T04:35:14.401Z" + }, + "logRequestState": { + "value": "idle", + "timestamp": "2025-07-02T04:35:14.401Z" + }, + "logInfo": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25040102, + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "endpoint": { + "value": "PIPER", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "VR0", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "tsId": { + "value": "DA10", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-07-02T04:35:13.556Z" + } + }, + "custom.hepaFilter": { + "hepaFilterCapacity": { + "value": null + }, + "hepaFilterStatus": { + "value": null + }, + "hepaFilterResetType": { + "value": null + }, + "hepaFilterUsageStep": { + "value": null + }, + "hepaFilterUsage": { + "value": null + }, + "hepaFilterLastResetDate": { + "value": null + } + }, + "samsungce.robotCleanerMapCleaningInfo": { + "area": { + "value": "None", + "timestamp": "2025-07-10T09:37:08.648Z" + }, + "cleanedExtent": { + "value": -1, + "unit": "m\u00b2", + "timestamp": "2025-07-10T09:37:08.648Z" + }, + "nearObject": { + "value": "None", + "timestamp": "2025-07-02T04:35:13.567Z" + }, + "remainingTime": { + "value": -1, + "unit": "minute", + "timestamp": "2025-07-10T06:42:57.820Z" + } + }, + "audioVolume": { + "volume": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 981, + "deltaEnergy": 21, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-07-10T11:11:22Z", + "end": "2025-07-10T11:20:22Z" + }, + "timestamp": "2025-07-10T11:20:22.600Z" + } + }, + "samsungce.robotCleanerMapList": { + "maps": { + "value": [ + { + "id": "1", + "name": "Map1", + "userEdited": false, + "createdTime": "2025-07-01T08:23:29Z", + "updatedTime": "2025-07-01T08:23:29Z", + "areaInfo": [ + { + "id": "1", + "name": "Room", + "userEdited": false + }, + { + "id": "2", + "name": "Room 2", + "userEdited": false + }, + { + "id": "3", + "name": "Room 3", + "userEdited": false + }, + { + "id": "4", + "name": "Room 4", + "userEdited": false + } + ], + "objectInfo": [] + } + ], + "timestamp": "2025-07-02T04:35:14.204Z" + } + }, + "samsungce.robotCleanerPatrol": { + "timezone": { + "value": null + }, + "patrolStatus": { + "value": null + }, + "areaIds": { + "value": null + }, + "timeOffset": { + "value": null + }, + "waypoints": { + "value": null + }, + "enabled": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "blockingStatus": { + "value": null + }, + "mapId": { + "value": null + }, + "startTime": { + "value": null + }, + "interval": { + "value": null + }, + "endTime": { + "value": null + }, + "obsoleted": { + "value": null + } + }, + "samsungce.robotCleanerAudioClip": { + "enabled": { + "value": null + } + }, + "samsungce.musicPlaylist": { + "currentTrack": { + "value": null + }, + "playlist": { + "value": null + } + }, + "audioNotification": {}, + "samsungce.robotCleanerPetMonitorReport": { + "report": { + "value": null + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": "none", + "timestamp": "2025-07-02T04:35:14.341Z" + } + }, + "samsungce.robotCleanerFeatureVisibility": { + "invisibleFeatures": { + "value": [ + "Start", + "Dock", + "SelectRoom", + "DustEmit", + "SelectSpot", + "CleaningMethod", + "MopWash", + "MopDry" + ], + "timestamp": "2025-07-10T09:52:40.298Z" + }, + "visibleFeatures": { + "value": [ + "Stop", + "Suction", + "Repeat", + "MapMerge", + "MapDivide", + "MySchedule", + "Homecare", + "CleanReport", + "CleanHistory", + "DND", + "Sound", + "NoEntryZone", + "RenameRoom", + "ResetMap", + "Accessory", + "CleaningOption", + "ObjectEdit", + "WaterLevel", + "ClimbZone" + ], + "timestamp": "2025-07-10T09:52:40.298Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G", "5G"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-07-02T04:35:14.461Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "25012310" + }, + { + "id": "1", + "swType": "Software", + "versionNumber": "25012310" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "25012100" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "24012200" + }, + { + "id": "4", + "swType": "Bixby", + "versionNumber": "(null)" + }, + { + "id": "5", + "swType": "Firmware", + "versionNumber": "25012200" + } + ], + "timestamp": "2025-07-02T04:35:13.556Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": { + "newVersion": "00000000", + "currentVersion": "00000000", + "moduleType": "mainController" + }, + "timestamp": "2025-07-09T23:00:32.385Z" + }, + "otnDUID": { + "value": "JHCDM7UU7UJWQ", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-07-02T04:35:19.823Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-07-02T04:35:19.823Z" + } + }, + "samsungce.robotCleanerReservation": { + "reservations": { + "value": [ + { + "id": "2", + "enabled": true, + "dayOfWeek": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + "startTime": "02:32", + "repeatMode": "weekly", + "cleaningMode": "auto" + } + ], + "timestamp": "2025-07-02T04:35:13.844Z" + }, + "maxNumberOfReservations": { + "value": null + } + }, + "audioMute": { + "mute": { + "value": null + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null + } + }, + "samsungce.robotCleanerMotorFilter": { + "motorFilterResetType": { + "value": ["washable"], + "timestamp": "2025-07-02T04:35:13.496Z" + }, + "motorFilterStatus": { + "value": "normal", + "timestamp": "2025-07-02T04:35:13.496Z" + } + }, + "samsungce.robotCleanerCleaningType": { + "cleaningType": { + "value": "vacuumAndMopTogether", + "timestamp": "2025-07-09T12:44:06.437Z" + }, + "supportedCleaningTypes": { + "value": ["vacuum", "mop", "vacuumAndMopTogether", "mopAfterVacuum"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "soundDetection": { + "soundDetectionState": { + "value": null + }, + "supportedSoundTypes": { + "value": null + }, + "soundDetected": { + "value": null + } + }, + "samsungce.robotCleanerWelcome": { + "coordinates": { + "value": null + } + }, + "samsungce.robotCleanerPetMonitor": { + "areaIds": { + "value": null + }, + "originator": { + "value": null + }, + "waypoints": { + "value": null + }, + "enabled": { + "value": null + }, + "excludeHolidays": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "monitoringStatus": { + "value": null + }, + "blockingStatus": { + "value": null + }, + "mapId": { + "value": null + }, + "startTime": { + "value": null + }, + "interval": { + "value": null + }, + "endTime": { + "value": null + }, + "obsoleted": { + "value": null + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 59, + "unit": "%", + "timestamp": "2025-07-10T11:24:13.441Z" + }, + "type": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "50029141", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "80010b0002d8411f0100000000000000", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "description": { + "value": "Jet Bot V/C", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "JETBOT_COMBO_9X00_24K", + "timestamp": "2025-07-09T23:00:26.764Z" + } + }, + "samsungce.robotCleanerSystemSoundMode": { + "soundMode": { + "value": "mute", + "timestamp": "2025-07-05T18:17:55.940Z" + }, + "supportedSoundModes": { + "value": ["mute", "beep", "voice"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-07-09T23:00:26.829Z" + } + }, + "samsungce.robotCleanerPetCleaningSchedule": { + "excludeHolidays": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "mapId": { + "value": null + }, + "areaIds": { + "value": null + }, + "startTime": { + "value": null + }, + "originator": { + "value": null + }, + "obsoleted": { + "value": true, + "timestamp": "2025-07-02T04:35:14.317Z" + }, + "enabled": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.234Z" + } + }, + "samsungce.microphoneSettings": { + "mute": { + "value": null + } + }, + "samsungce.robotCleanerMapAreaInfo": { + "areaInfo": { + "value": [ + { + "id": "1", + "name": "Room" + }, + { + "id": "2", + "name": "Room 2" + }, + { + "id": "3", + "name": "Room 3" + }, + { + "id": "4", + "name": "Room 4" + } + ], + "timestamp": "2025-07-03T02:33:15.133Z" + } + }, + "samsungce.audioVolumeLevel": { + "volumeLevel": { + "value": 0, + "timestamp": "2025-07-05T18:17:55.915Z" + }, + "volumeLevelRange": { + "value": { + "minimum": 0, + "maximum": 3, + "step": 1 + }, + "timestamp": "2025-07-02T04:35:13.837Z" + } + }, + "robotCleanerMovement": { + "robotCleanerMovement": { + "value": "cleaning", + "timestamp": "2025-07-10T09:38:52.938Z" + } + }, + "samsungce.robotCleanerSafetyPatrol": { + "personDetection": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.461Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["refill-drainage-kit"], + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "videoCapture": { + "stream": { + "value": null + }, + "clip": { + "value": null + } + }, + "samsungce.robotCleanerWaterSprayLevel": { + "availableWaterSprayLevels": { + "value": null + }, + "waterSprayLevel": { + "value": "mediumLow", + "timestamp": "2025-07-10T11:00:35.545Z" + }, + "supportedWaterSprayLevels": { + "value": ["high", "mediumHigh", "medium", "mediumLow", "low"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "samsungce.robotCleanerMapMetadata": { + "cellSize": { + "value": 20, + "unit": "mm", + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "samsungce.robotCleanerGuidedPatrol": { + "mapId": { + "value": null + }, + "waypoints": { + "value": null + } + }, + "audioTrackAddressing": {}, + "refresh": {}, + "samsungce.robotCleanerOperatingState": { + "supportedOperatingState": { + "value": [ + "homing", + "charging", + "charged", + "chargingForRemainingJob", + "moving", + "cleaning", + "paused", + "idle", + "error", + "powerSaving", + "factoryReset", + "relocal", + "exploring", + "processing", + "emitDust", + "washingMop", + "sterilizingMop", + "dryingMop", + "supplyingWater", + "preparingWater", + "spinDrying", + "flexCharged", + "descaling", + "drainingWater", + "waitingForDescaling" + ], + "timestamp": "2025-06-20T14:12:58.012Z" + }, + "operatingState": { + "value": "charging", + "timestamp": "2025-07-10T09:52:40.510Z" + }, + "cleaningStep": { + "value": "none", + "timestamp": "2025-07-10T09:37:07.214Z" + }, + "homingReason": { + "value": "none", + "timestamp": "2025-07-10T09:37:45.152Z" + }, + "isMapBasedOperationAvailable": { + "value": false, + "timestamp": "2025-07-10T09:37:55.690Z" + } + }, + "samsungce.soundDetectionSensitivity": { + "level": { + "value": null + }, + "supportedLevels": { + "value": null + } + }, + "samsungce.robotCleanerMonitoringAutomation": {}, + "mediaPlaybackRepeat": { + "playbackRepeatMode": { + "value": null + } + }, + "imageCapture": { + "image": { + "value": null + }, + "encrypted": { + "value": null + }, + "captureTime": { + "value": null + } + }, + "samsungce.robotCleanerCleaningMode": { + "supportedCleaningMode": { + "value": [ + "auto", + "area", + "spot", + "stop", + "uncleanedObject", + "patternMap" + ], + "timestamp": "2025-06-20T14:12:58.012Z" + }, + "repeatModeEnabled": { + "value": true, + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "supportRepeatMode": { + "value": true, + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "cleaningMode": { + "value": "stop", + "timestamp": "2025-07-10T09:37:07.214Z" + } + }, + "samsungce.robotCleanerAvpRegistration": { + "registrationStatus": { + "value": null + } + }, + "samsungce.robotCleanerDrivingMode": { + "drivingMode": { + "value": "areaThenWalls", + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "supportedDrivingModes": { + "value": ["areaThenWalls", "wallFirst", "quickCleaningZigzagPattern"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "robotCleanerCleaningMode": { + "robotCleanerCleaningMode": { + "value": "stop", + "timestamp": "2025-07-10T09:37:07.214Z" + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": "off", + "timestamp": "2025-07-02T04:35:13.622Z" + }, + "startTime": { + "value": "0000", + "timestamp": "2025-07-02T04:35:13.622Z" + }, + "endTime": { + "value": "0000", + "timestamp": "2025-07-02T04:35:13.622Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "on", + "timestamp": "2025-07-10T11:20:40.419Z" + }, + "supportedBrightnessLevel": { + "value": ["on", "off"], + "timestamp": "2025-06-20T14:12:57.383Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aq_sensor_3_ikea.json b/tests/components/smartthings/fixtures/devices/aq_sensor_3_ikea.json new file mode 100644 index 00000000000..dc4c4821587 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aq_sensor_3_ikea.json @@ -0,0 +1,77 @@ +{ + "items": [ + { + "deviceId": "e44d4e5c-45ea-498f-a653-f5d0c3d97bb8", + "name": "humidity-temp-dust-tvoc", + "label": "aq-sensor-3-ikea", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "a39f5e57-c861-3904-9567-acda80b7cf2d", + "deviceManufacturerCode": "IKEA of Sweden", + "locationId": "9fbc89a0-5c32-494d-9a49-68186d6a5387", + "ownerId": "d051c4c5-8ccb-47b9-87ee-7ebb99694b9f", + "roomId": "f5ce3177-e0a6-4415-8496-bafa1611ee62", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "fineDustSensor", + "version": 1 + }, + { + "id": "airQualityHealthConcern", + "version": 1 + }, + { + "id": "tvocMeasurement", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "AirQualityDetector", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-03-19T19:48:39.571Z", + "parentDeviceId": "5616f552-5bae-4a1f-94b3-9eb2673a1b28", + "profile": { + "id": "0720a973-6923-39d4-8991-3aaed6edf5d5" + }, + "zigbee": { + "eui": "0CAE5FFFFECE4328", + "networkId": "846B", + "driverId": "9bee78b3-204e-4118-8265-8767f9152c49", + "executingLocally": true, + "hubId": "5616f552-5bae-4a1f-94b3-9eb2673a1b28", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json new file mode 100644 index 00000000000..2669768b719 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json @@ -0,0 +1,588 @@ +{ + "items": [ + { + "deviceId": "271d82e0-5b0c-e4b8-058e-cdf23a188610", + "name": "Samsung-Refrigerator", + "label": "Lod\u00f3wka", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "5274d210-9bd8-4a14-ae55-52a9ffeedfb7", + "ownerId": "d40034d0-c87b-3fa6-da98-108c42c36a6b", + "roomId": "b19fa610-62f8-4109-b9cc-47f85fcefd29", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.driverState", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.fridgeWelcomeLighting", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "specialzone-01", + "label": "specialzone-01", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-08-12T12:35:56.924Z", + "profile": { + "id": "840ff773-857b-324b-a54e-ba31a8155c4d" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Samsung-Refrigerator", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "platformVersion": "SYSTEM 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "A-RFWW-TP1-24-T4-COM_20250706", + "vendorId": "DA-REF-NORMAL-01011", + "vendorResourceClientServerVersion": "MediaTek Release 250706", + "lastSignupTime": "2025-08-12T12:35:56.864318132Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "RR39C7EC5B1/EF" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json new file mode 100644 index 00000000000..f25797f2dcf --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json @@ -0,0 +1,353 @@ +{ + "items": [ + { + "deviceId": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "name": "[robot vacuum] Samsung", + "label": "Robot vacuum", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-RVC-MAP-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d31d0982-9bf9-4f0c-afd4-ad3d78842541", + "ownerId": "85532262-6537-54d9-179a-333db98dbcc0", + "roomId": "572f5713-53a9-4fb8-85fd-60515e44f1ed", + "deviceTypeName": "Samsung OCF Robot Vacuum", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "audioTrackAddressing", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "imageCapture", + "version": 1 + }, + { + "id": "logTrigger", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaPlaybackRepeat", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "robotCleanerMovement", + "version": 1 + }, + { + "id": "robotCleanerTurboMode", + "version": 1 + }, + { + "id": "soundDetection", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "videoCapture", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.hepaFilter", + "version": 1 + }, + { + "id": "samsungce.audioVolumeLevel", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.microphoneSettings", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.musicPlaylist", + "version": 1 + }, + { + "id": "samsungce.robotCleanerDrivingMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningType", + "version": 1 + }, + { + "id": "samsungce.robotCleanerOperatingState", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapAreaInfo", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapCleaningInfo", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPatrol", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetCleaningSchedule", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetMonitor", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetMonitorReport", + "version": 1 + }, + { + "id": "samsungce.robotCleanerReservation", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMotorFilter", + "version": 1 + }, + { + "id": "samsungce.robotCleanerAvpRegistration", + "version": 1 + }, + { + "id": "samsungce.soundDetectionSensitivity", + "version": 1 + }, + { + "id": "samsungce.robotCleanerWaterSprayLevel", + "version": 1 + }, + { + "id": "samsungce.robotCleanerWelcome", + "version": 1 + }, + { + "id": "samsungce.robotCleanerAudioClip", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMonitoringAutomation", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapMetadata", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapList", + "version": 1 + }, + { + "id": "samsungce.robotCleanerSystemSoundMode", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.robotCleanerFeatureVisibility", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.robotCleanerGuidedPatrol", + "version": 1 + }, + { + "id": "samsungce.robotCleanerSafetyPatrol", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "RobotCleaner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "station", + "label": "station", + "capabilities": [ + { + "id": "custom.hepaFilter", + "version": 1 + }, + { + "id": "samsungce.robotCleanerDustBag", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "refill-drainage-kit", + "label": "refill-drainage-kit", + "capabilities": [ + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.drainFilter", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.activationState", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-06-20T14:12:56.260Z", + "profile": { + "id": "5d345d41-a497-3fc7-84fe-eaaee50f0509" + }, + "ocf": { + "ocfDeviceType": "oic.d.robotcleaner", + "name": "[robot vacuum] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "JETBOT_COMBO_9X00_24K|50029141|80010b0002d8411f0100000000000000", + "platformVersion": "1.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250123.105306", + "vendorId": "DA-RVC-MAP-01011", + "vendorResourceClientServerVersion": "4.0.38", + "lastSignupTime": "2025-06-20T14:12:56.202953160Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false, + "modelCode": "NONE" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 7be4d3af55b..4637de49efb 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -1169,6 +1169,55 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][binary_sensor.lodowka_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.lodowka_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][binary_sensor.lodowka_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Lodówka Door', + }), + 'context': , + 'entity_id': 'binary_sensor.lodowka_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 446eca63fb2..d732578212a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -20,7 +20,6 @@ '7c16163e-c94e-482f-95f6-139ae0cd9d5e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -30,7 +29,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -53,7 +51,6 @@ 'f0af21a2-d5a1-437c-b10a-b34a87394b71', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -63,7 +60,37 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Toilet', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[aq_sensor_3_ikea] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'aq-sensor-3-ikea', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, 'sw_version': None, 'via_device_id': None, }) @@ -86,7 +113,6 @@ 'bf53a150-f8a4-45d1-aac4-86252475d551', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -96,7 +122,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -119,7 +144,6 @@ '68e786a6-7f61-4c3a-9e13-70b803cf782b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -129,7 +153,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -152,7 +175,6 @@ '286ba274-4093-4bcb-849c-a1a3efe7b1e5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -162,7 +184,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -185,7 +206,6 @@ '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Arlo', @@ -195,7 +215,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -218,7 +237,6 @@ '571af102-15db-4030-b76b-245a691f74a5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WonderLabs Company', @@ -228,7 +246,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -251,7 +268,6 @@ 'd0268a69-abfb-4c92-a646-61cec2e510ad', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -261,7 +277,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -284,7 +299,6 @@ '2d9a892b-1c93-45a5-84cb-0e81889498c6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -294,7 +308,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -317,7 +330,6 @@ 'a3a970ea-e09c-9c04-161b-94c934e21666', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -327,7 +339,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', 'via_device_id': None, }) @@ -350,7 +361,6 @@ '4165c51e-bf6b-c5b6-fd53-127d6248754b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -360,7 +370,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'AEH-WW-TP1-22-AE6000_17240903', 'via_device_id': None, }) @@ -383,7 +392,6 @@ '96a5ef74-5832-a84b-f1f7-ca799957065d', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -393,7 +401,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -416,7 +423,6 @@ 'c76d6f38-1b7f-13dd-37b5-db18d5272783', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -426,7 +432,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'ARTIK051_PRAC_20K_11230313', 'via_device_id': None, }) @@ -449,7 +454,6 @@ '4ece486b-89db-f06a-d54d-748b676b4d8e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -459,7 +463,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, }) @@ -482,7 +485,6 @@ 'F8042E25-0E53-0000-0000-000000000000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -492,7 +494,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -515,7 +516,6 @@ '808dbd84-f357-47e2-a0cd-3b66fa22d584', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -525,7 +525,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -548,7 +547,6 @@ '2bad3237-4886-e699-1b90-4a51a3d55c8a', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -558,7 +556,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, }) @@ -581,7 +578,6 @@ '9447959a-0dfa-6b27-d40d-650da525c53f', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -591,7 +587,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', 'via_device_id': None, }) @@ -614,7 +609,6 @@ '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -624,7 +618,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', 'via_device_id': None, }) @@ -647,7 +640,6 @@ '7db87911-7dce-1cf2-7119-b953432a2f09', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -657,7 +649,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, }) @@ -680,7 +671,6 @@ '7d3feb98-8a36-4351-c362-5e21ad3a78dd', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -690,7 +680,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20240616.213423', 'via_device_id': None, }) @@ -713,7 +702,6 @@ '5758b2ec-563e-f39b-ec39-208e54aabf60', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -723,11 +711,72 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', 'via_device_id': None, }) # --- +# name: test_devices[da_ref_normal_01011_onedoor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '271d82e0-5b0c-e4b8-058e-cdf23a188610', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_REF_21K', + 'model_id': None, + 'name': 'Lodówka', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': 'A-RFWW-TP1-24-T4-COM_20250706', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_rvc_map_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '05accb39-2017-c98b-a5ab-04a81f4d3d9a', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'JETBOT_COMBO_9X00_24K', + 'model_id': None, + 'name': 'Robot vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': '20250123.105306', + 'via_device_id': None, + }) +# --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', @@ -746,7 +795,6 @@ '3442dfc6-17c0-a65f-dae0-4c6e01786f44', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -756,7 +804,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': '1.0', 'via_device_id': None, }) @@ -779,7 +826,6 @@ '1f98ebd0-ac48-d802-7f62-000001200100', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -789,7 +835,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20250317.1', 'via_device_id': None, }) @@ -812,7 +857,6 @@ '6a7d5349-0a66-0277-058d-000001200101', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -822,7 +866,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20250317.1', 'via_device_id': None, }) @@ -845,7 +888,6 @@ '3810e5ad-5351-d9f9-12ff-000001200000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -855,7 +897,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20250317.1', 'via_device_id': None, }) @@ -878,7 +919,6 @@ 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -888,7 +928,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, }) @@ -911,7 +950,6 @@ 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -921,7 +959,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', 'via_device_id': None, }) @@ -944,7 +981,6 @@ '02f7256e-8353-5bdd-547f-bd5b1647e01b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -954,7 +990,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) @@ -977,7 +1012,6 @@ '3a6c4e05-811d-5041-e956-3d04c424cbcd', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -987,7 +1021,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) @@ -1010,7 +1043,6 @@ 'f984b91d-f250-9d42-3436-33f09a422a47', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1020,7 +1052,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, }) @@ -1043,7 +1074,6 @@ '63803fae-cbed-f356-a063-2cf148ae3ca7', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1053,7 +1083,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) @@ -1076,7 +1105,6 @@ 'b854ca5f-dc54-140d-6349-758b4d973c41', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1086,7 +1114,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30240927', 'via_device_id': None, }) @@ -1109,7 +1136,6 @@ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1119,7 +1145,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1142,7 +1167,6 @@ 'd5dc3299-c266-41c7-bd08-f540aea54b89', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ecobee', @@ -1152,7 +1176,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '250206213001', 'via_device_id': None, }) @@ -1175,7 +1198,6 @@ '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ecobee', @@ -1185,7 +1207,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '250206151734', 'via_device_id': None, }) @@ -1208,7 +1229,6 @@ '1888b38f-6246-4f1e-911b-bfcfb66999db', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ecobee', @@ -1218,7 +1238,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '250308073247', 'via_device_id': None, }) @@ -1241,7 +1260,6 @@ 'f1af21a2-d5a1-437c-b10a-b34a87394b71', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1251,7 +1269,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1274,7 +1291,6 @@ '3b57dca3-9a90-4f27-ba80-f947b1e60d58', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'CopperLabs', @@ -1284,7 +1300,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1307,7 +1322,6 @@ 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1317,7 +1331,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1340,7 +1353,6 @@ '656569c2-7976-4232-a789-34b4d1176c3a', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1350,7 +1362,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1373,7 +1384,6 @@ '6d95a8b7-4ee3-429a-a13a-00ec9354170c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1383,7 +1393,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1406,7 +1415,6 @@ '5e5b97f3-3094-44e6-abc0-f61283412d6a', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1416,7 +1424,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1439,7 +1446,6 @@ '69a271f6-6537-4982-8cd9-979866872692', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1449,7 +1455,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1472,7 +1477,6 @@ '440063de-a200-40b5-8a6b-f3399eaa0370', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Signify Netherlands B.V.', @@ -1482,7 +1486,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.122.2', 'via_device_id': None, }) @@ -1505,7 +1508,6 @@ 'cb958955-b015-498c-9e62-fc0c51abd054', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Signify Netherlands B.V.', @@ -1515,7 +1517,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.122.2', 'via_device_id': None, }) @@ -1538,7 +1539,6 @@ 'afcf3b91-0000-1111-2222-ddff2a0a6577', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1548,7 +1548,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'HW-Q80RWWB-1012.6', 'via_device_id': None, }) @@ -1571,7 +1570,6 @@ '71afed1c-006d-4e48-b16e-e7f88f9fd638', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1581,7 +1579,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1604,7 +1601,6 @@ '83d660e4-b0c8-4881-a674-d9f1730366c1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1614,7 +1610,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1637,7 +1632,6 @@ 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1647,7 +1641,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'V310XXU1AWK1', 'via_device_id': None, }) @@ -1670,7 +1663,6 @@ '184c67cc-69e2-44b6-8f73-55c963068ad9', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1680,7 +1672,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1703,7 +1694,6 @@ '692ea4e9-2022-4ed8-8a57-1b884a59cc38', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1713,7 +1703,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1736,7 +1725,6 @@ '7d246592-93db-4d72-a10d-5a51793ece8c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1746,7 +1734,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1769,7 +1756,6 @@ '2409a73c-918a-4d1f-b4f5-c27468c71d70', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Emerson', @@ -1779,7 +1765,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '6004971003', 'via_device_id': None, }) @@ -1802,7 +1787,6 @@ 'bf4b1167-48a3-4af7-9186-0900a678ffa5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -1812,7 +1796,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'SKY40147', 'via_device_id': None, }) @@ -1835,7 +1818,6 @@ '550a1c72-65a0-4d55-b97b-75168e055398', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1845,7 +1827,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1868,7 +1849,6 @@ 'c85fced9-c474-4a47-93c2-037cc7829536', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1878,7 +1858,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1901,7 +1880,6 @@ '6602696a-1e48-49e4-919f-69406f5b5da1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -1911,7 +1889,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.3.1 Build 240621 Rel.162048', 'via_device_id': None, }) @@ -1934,7 +1911,6 @@ '0d94e5db-8501-2355-eb4f-214163702cac', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1944,7 +1920,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, }) @@ -1967,7 +1942,6 @@ 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1977,7 +1951,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'SAT-MT8532D24WWC-1016.0', 'via_device_id': None, }) @@ -2000,7 +1973,6 @@ '5cc1c096-98b9-460c-8f1c-1045509ec605', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -2010,7 +1982,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'latest', 'via_device_id': None, }) @@ -2033,7 +2004,6 @@ '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -2043,7 +2013,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, }) @@ -2066,7 +2035,6 @@ '2894dc93-0f11-49cc-8a81-3a684cebebf6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2076,7 +2044,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2099,7 +2066,6 @@ '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2109,7 +2075,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2132,7 +2097,6 @@ 'a2a6018b-2663-4727-9d1d-8f56953b5116', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2142,7 +2106,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2165,7 +2128,6 @@ 'a9f587c5-5d8b-4273-8907-e7f609af5158', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2175,7 +2137,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2202,7 +2163,6 @@ '074fa784-8be8-4c70-8e22-6f5ed6f81b7e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2212,7 +2172,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': '000.055.00005', 'via_device_id': None, }) diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 7dd57e89c6a..d36132cc1ef 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_all_entities[da_ks_oven_01061][select.oven_lamp-entry] @@ -112,7 +112,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'high', }) # --- # name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-entry] @@ -172,6 +172,63 @@ 'state': 'extra_high', }) # --- +# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.robot_vacuum_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Lamp', + 'options': list([ + 'on', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.robot_vacuum_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index f88524116ee..7109b46cebb 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -163,6 +163,221 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aq_sensor_3_ikea_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'aq-sensor-3-ikea Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aq_sensor_3_ikea_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_fineDustSensor_fineDustLevel_fineDustLevel', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'aq-sensor-3-ikea PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aq_sensor_3_ikea_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'aq-sensor-3-ikea Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_volatile_organic_compounds_parts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aq_sensor_3_ikea_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main_tvocMeasurement_tvocLevel_tvocLevel', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'aq-sensor-3-ikea Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.aq_sensor_3_ikea_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- # name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -951,7 +1166,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_veryFineDustSensor_veryFineDustLevel_veryFineDustLevel', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-state] @@ -960,7 +1175,7 @@ 'device_class': 'pm1', 'friendly_name': '에어모니터 플러스 PM1', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', @@ -1004,7 +1219,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_dustLevel_dustLevel', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-state] @@ -1013,7 +1228,7 @@ 'device_class': 'pm10', 'friendly_name': '에어모니터 플러스 PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', @@ -1057,7 +1272,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_fineDustLevel_fineDustLevel', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-state] @@ -1066,7 +1281,7 @@ 'device_class': 'pm25', 'friendly_name': '에어모니터 플러스 PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm2_5', @@ -2820,7 +3035,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_dustLevel_dustLevel', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-state] @@ -2829,7 +3044,7 @@ 'device_class': 'pm10', 'friendly_name': 'Corridor A/C PM10', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.corridor_a_c_pm10', @@ -2873,7 +3088,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_fineDustLevel_fineDustLevel', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-state] @@ -2882,7 +3097,7 @@ 'device_class': 'pm25', 'friendly_name': 'Corridor A/C PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.corridor_a_c_pm2_5', @@ -6066,6 +6281,822 @@ 'state': '97', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lodowka_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.861', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lodowka_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lodowka_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lodowka_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Lodówka Power', + 'power_consumption_end': '2025-08-14T07:21:35Z', + 'power_consumption_start': '2025-08-14T07:04:50Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lodowka_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.00027936416665713', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_cleaning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_cleaning_mode', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerCleaningMode_robotCleanerCleaningMode_robotCleanerCleaningMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_cleaning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Cleaning mode', + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.981', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_movement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_movement', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Movement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_movement', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerMovement_robotCleanerMovement_robotCleanerMovement', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_movement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Movement', + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_movement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cleaning', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Robot vacuum Power', + 'power_consumption_end': '2025-07-10T11:20:22Z', + 'power_consumption_start': '2025-07-10T11:11:22Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_turbo_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turbo mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_turbo_mode', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerTurboMode_robotCleanerTurboMode_robotCleanerTurboMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Turbo mode', + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'extra_silence', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d0ea3dbcdad..6512e88998b 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -623,6 +623,102 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][switch.lodowka_power_cool-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lodowka_power_cool', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power cool', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_cool', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_samsungce.powerCool_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][switch.lodowka_power_cool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lodówka Power cool', + }), + 'context': , + 'entity_id': 'switch.lodowka_power_cool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][switch.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.robot_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][switch.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + }), + 'context': , + 'entity_id': 'switch.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr index 3191411a429..eb6b99b3363 100644 --- a/tests/components/smartthings/snapshots/test_update.ambr +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -1,4 +1,65 @@ # serializer version: 1 +# name: test_all_entities[aq_sensor_3_ikea][update.aq_sensor_3_ikea_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.aq_sensor_3_ikea_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'e44d4e5c-45ea-498f-a653-f5d0c3d97bb8_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aq_sensor_3_ikea][update.aq_sensor_3_ikea_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'aq-sensor-3-ikea Firmware', + 'in_progress': False, + 'installed_version': '00010010', + 'latest_version': '00010010', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.aq_sensor_3_ikea_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_vacuum.ambr b/tests/components/smartthings/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..59bbae2b3e7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_vacuum.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/smartthings/test_vacuum.py b/tests/components/smartthings/test_vacuum.py new file mode 100644 index 00000000000..6e2406625eb --- /dev/null +++ b/tests/components/smartthings/test_vacuum.py @@ -0,0 +1,133 @@ +"""Test for the SmartThings vacuum platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.VACUUM) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_START, Command.START), + (SERVICE_PAUSE, Command.PAUSE), + (SERVICE_RETURN_TO_BASE, Command.RETURN_TO_HOME), + ], +) +async def test_vacuum_actions( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test vacuum actions.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VACUUM_DOMAIN, + action, + {ATTR_ENTITY_ID: "vacuum.robot_vacuum"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + command, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_update( + hass, + devices, + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + "error", + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.ERROR + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.OFFLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.ONLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE diff --git a/tests/components/smarttub/conftest.py b/tests/components/smarttub/conftest.py index 06780f8fb1e..f7677100aad 100644 --- a/tests/components/smarttub/conftest.py +++ b/tests/components/smarttub/conftest.py @@ -81,6 +81,16 @@ def mock_spa(spa_state): spa_state.lights = [mock_light_off, mock_light_on] + mock_cover_sensor = create_autospec(smarttub.SpaSensor, instance=True) + mock_cover_sensor.spa = mock_spa + mock_cover_sensor.address = "address1" + mock_cover_sensor.name = "{cover-sensor-1}" + mock_cover_sensor.type = "ibs0x" + mock_cover_sensor.subType = "magnet" + mock_cover_sensor.magnet = True # closed + + spa_state.sensors = [mock_cover_sensor] + mock_filter_reminder = create_autospec(smarttub.SpaReminder, instance=True) mock_filter_reminder.id = "FILTER01" mock_filter_reminder.name = "MyFilter" @@ -127,6 +137,7 @@ def mock_spa_state(): "cleanupCycle": "INACTIVE", "lights": [], "pumps": [], + "sensors": [], }, ) diff --git a/tests/components/smarttub/test_binary_sensor.py b/tests/components/smarttub/test_binary_sensor.py index 3365b03b041..cf5676aa0bb 100644 --- a/tests/components/smarttub/test_binary_sensor.py +++ b/tests/components/smarttub/test_binary_sensor.py @@ -104,3 +104,14 @@ async def test_reset_reminder(spa, setup_entry, hass: HomeAssistant) -> None: ) reminder.reset.assert_called_with(days) + + +async def test_cover_sensor(hass: HomeAssistant, spa, setup_entry) -> None: + """Test cover sensor.""" + + entity_id = f"binary_sensor.{spa.brand}_{spa.model}_cover_sensor_1" + + state = hass.states.get(entity_id) + assert state is not None + + assert state.state == STATE_OFF # closed diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index a292cc97f47..989e95bde7e 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '01JAZ5DPW8C62D620DGYNG2R8H', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Salda', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 127, 'via_device_id': None, }) diff --git a/tests/components/smhi/snapshots/test_sensor.ambr b/tests/components/smhi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8fbdf229494 --- /dev/null +++ b/tests/components/smhi/snapshots/test_sensor.ambr @@ -0,0 +1,370 @@ +# serializer version: 1 +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_frozen_precipitation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Frozen precipitation', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frozen_precipitation', + 'unique_id': '59.32624, 17.84197-frozen_precipitation', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Frozen precipitation', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_frozen_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_high_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'High cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_cloud', + 'unique_id': '59.32624, 17.84197-high_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test High cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_high_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_low_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Low cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_cloud', + 'unique_id': '59.32624, 17.84197-low_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Low cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_low_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_medium_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Medium cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'medium_cloud', + 'unique_id': '59.32624, 17.84197-medium_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Medium cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_medium_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_precipitation_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation category', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_category', + 'unique_id': '59.32624, 17.84197-precipitation_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'device_class': 'enum', + 'friendly_name': 'Test Precipitation category', + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_precipitation_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_thunder_probability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thunder probability', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thunder', + 'unique_id': '59.32624, 17.84197-thunder', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Thunder probability', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_thunder_probability', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_total_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cloud', + 'unique_id': '59.32624, 17.84197-total_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Total cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_total_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index 083dcbd6404..2df5bb01a3c 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -68,7 +68,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -287,7 +287,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -299,3 +299,291 @@ 'wind_speed_unit': , }) # --- +# name: test_twice_daily_forecast_service[load_platforms0] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'fog', + 'datetime': '2023-08-07T08:00:00+00:00', + 'humidity': 100, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 992.4, + 'temperature': 18.4, + 'templow': 18.4, + 'wind_bearing': 93, + 'wind_gust_speed': 22.32, + 'wind_speed': 9.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00+00:00', + 'humidity': 96, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 991.7, + 'temperature': 18.4, + 'templow': 17.1, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-08T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 987.5, + 'temperature': 18.4, + 'templow': 14.8, + 'wind_bearing': 357, + 'wind_gust_speed': 10.44, + 'wind_speed': 3.96, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'humidity': 97, + 'is_daytime': True, + 'precipitation': 0.3, + 'pressure': 984.1, + 'temperature': 18.4, + 'templow': 12.8, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-09T00:00:00+00:00', + 'humidity': 85, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 995.6, + 'temperature': 18.4, + 'templow': 11.2, + 'wind_bearing': 193, + 'wind_gust_speed': 48.6, + 'wind_speed': 19.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'humidity': 95, + 'is_daytime': True, + 'precipitation': 1.1, + 'pressure': 1001.4, + 'temperature': 18.4, + 'templow': 11.1, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-10T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 3.6, + 'pressure': 1007.8, + 'temperature': 18.4, + 'templow': 10.4, + 'wind_bearing': 200, + 'wind_gust_speed': 28.08, + 'wind_speed': 14.4, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00+00:00', + 'humidity': 75, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1011.1, + 'temperature': 18.4, + 'templow': 13.9, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T00:00:00+00:00', + 'humidity': 98, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1012.3, + 'temperature': 18.4, + 'templow': 11.7, + 'wind_bearing': 169, + 'wind_gust_speed': 16.56, + 'wind_speed': 7.56, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00+00:00', + 'humidity': 69, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 18.4, + 'templow': 17.6, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2023-08-12T00:00:00+00:00', + 'humidity': 97, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.8, + 'temperature': 18.4, + 'templow': 12.3, + 'wind_bearing': 191, + 'wind_gust_speed': 18.0, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00+00:00', + 'humidity': 82, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 18.4, + 'templow': 17.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 12, + 'condition': 'clear-night', + 'datetime': '2023-08-13T00:00:00+00:00', + 'humidity': 92, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1013.9, + 'temperature': 18.4, + 'templow': 13.6, + 'wind_bearing': 233, + 'wind_gust_speed': 20.16, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00+00:00', + 'humidity': 59, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1013.6, + 'temperature': 20.0, + 'templow': 18.4, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T00:00:00+00:00', + 'humidity': 91, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.2, + 'temperature': 18.4, + 'templow': 13.5, + 'wind_bearing': 227, + 'wind_gust_speed': 23.4, + 'wind_speed': 10.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00+00:00', + 'humidity': 56, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 20.8, + 'templow': 18.4, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-15T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 14.3, + 'wind_bearing': 196, + 'wind_gust_speed': 22.32, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00+00:00', + 'humidity': 64, + 'is_daytime': True, + 'precipitation': 2.4, + 'pressure': 1014.3, + 'temperature': 20.4, + 'templow': 18.4, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 38, + 'condition': 'clear-night', + 'datetime': '2023-08-16T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 13.8, + 'wind_bearing': 228, + 'wind_gust_speed': 21.24, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00+00:00', + 'humidity': 61, + 'is_daytime': True, + 'precipitation': 1.2, + 'pressure': 1014.0, + 'temperature': 20.2, + 'templow': 18.4, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- diff --git a/tests/components/smhi/test_sensor.py b/tests/components/smhi/test_sensor.py new file mode 100644 index 00000000000..a56340af1b5 --- /dev/null +++ b/tests/components/smhi/test_sensor.py @@ -0,0 +1,26 @@ +"""Test for the smhi weather entity.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SENSOR]], +) +async def test_sensor_setup( + hass: HomeAssistant, + entity_registry: EntityRegistry, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test for successfully setting up the smhi sensors.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 5cf8c2ae41d..9acacb10ffa 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -473,3 +473,23 @@ async def test_forecast_service( return_response=True, ) assert response == snapshot + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.WEATHER]], +) +async def test_twice_daily_forecast_service( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast service.""" + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {"entity_id": ENTITY_ID, "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == snapshot diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index ba374199254..7f46daef13c 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'id': , 'identifiers': set({ }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'SMLIGHT', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'core: v2.3.6 / zigbee: 20240314', 'via_device_id': None, }) diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py index a325bd41bd7..69bf252f53a 100644 --- a/tests/components/snapcast/__init__.py +++ b/tests/components/snapcast/__init__.py @@ -1 +1,13 @@ """Tests for the Snapcast integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Snapcast integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py index bcc0ac5bc30..282429b110a 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -1,9 +1,18 @@ """Test the snapcast config flow.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest +from snapcast.control.client import Snapclient +from snapcast.control.group import Snapgroup +from snapcast.control.server import CONTROL_PORT +from snapcast.control.stream import Snapstream + +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry @pytest.fixture @@ -16,10 +25,178 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_create_server() -> Generator[AsyncMock]: +def mock_server(mock_create_server: AsyncMock) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snapcast.config_flow.snapcast.control.create_server", + return_value=mock_create_server, + ) as mock_server: + yield mock_server + + +@pytest.fixture +def mock_create_server( + mock_group_1: AsyncMock, + mock_group_2: AsyncMock, + mock_client_1: AsyncMock, + mock_client_2: AsyncMock, + mock_stream_1: AsyncMock, + mock_stream_2: AsyncMock, +) -> Generator[AsyncMock]: """Create mock snapcast connection.""" - mock_connection = AsyncMock() - mock_connection.start = AsyncMock(return_value=None) - mock_connection.stop = MagicMock() - with patch("snapcast.control.create_server", return_value=mock_connection): - yield mock_connection + with patch( + "homeassistant.components.snapcast.coordinator.Snapserver", autospec=True + ) as mock_snapserver: + mock_server = mock_snapserver.return_value + mock_server.groups = [mock_group_1, mock_group_2] + mock_server.clients = [mock_client_1, mock_client_2] + mock_server.streams = [mock_stream_1, mock_stream_2] + + def get_stream(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.streams}[identifier] + + def get_group(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.groups}[identifier] + + def get_client(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.clients}[identifier] + + mock_server.stream = get_stream + mock_server.group = get_group + mock_server.client = get_client + + mock_client_1.groups_available = lambda: mock_server.groups + mock_client_2.groups_available = lambda: mock_server.groups + + yield mock_server + + +@pytest.fixture +async def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + + # Create a mock config entry + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: CONTROL_PORT, + }, + ) + + +@pytest.fixture +def mock_group_1(mock_stream_1: AsyncMock, streams: dict[str, AsyncMock]) -> AsyncMock: + """Create a mock Snapgroup.""" + group = AsyncMock(spec=Snapgroup) + group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e1" + group.name = "test_group_1" + group.friendly_name = "Test Group 1" + group.stream = mock_stream_1.identifier + group.muted = False + group.stream_status = mock_stream_1.status + group.volume = 48 + group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()} + return group + + +@pytest.fixture +def mock_group_2(mock_stream_2: AsyncMock, streams: dict[str, AsyncMock]) -> AsyncMock: + """Create a mock Snapgroup.""" + group = AsyncMock(spec=Snapgroup) + group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e2" + group.name = "test_group_2" + group.friendly_name = "Test Group 2" + group.stream = mock_stream_2.identifier + group.muted = False + group.stream_status = mock_stream_2.status + group.volume = 65 + group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()} + return group + + +@pytest.fixture +def mock_client_1(mock_group_1: AsyncMock) -> AsyncMock: + """Create a mock Snapclient.""" + client = AsyncMock(spec=Snapclient) + client.identifier = "00:21:6a:7d:74:fc#1" + client.friendly_name = "test_client_1" + client.version = "0.10.0" + client.connected = True + client.name = "Snapclient 1" + client.latency = 6 + client.muted = False + client.volume = 48 + client.group = mock_group_1 + mock_group_1.clients = [client.identifier] + return client + + +@pytest.fixture +def mock_client_2(mock_group_2: AsyncMock) -> AsyncMock: + """Create a mock Snapclient.""" + client = AsyncMock(spec=Snapclient) + client.identifier = "00:21:6a:7d:74:fc#2" + client.friendly_name = "test_client_2" + client.version = "0.10.0" + client.connected = True + client.name = "Snapclient 2" + client.latency = 6 + client.muted = False + client.volume = 100 + client.group = mock_group_2 + mock_group_2.clients = [client.identifier] + return client + + +@pytest.fixture +def mock_stream_1() -> AsyncMock: + """Create a mock stream.""" + stream = AsyncMock(spec=Snapstream) + stream.identifier = "test_stream_1" + stream.status = "playing" + stream.name = "Test Stream 1" + stream.friendly_name = "Test Stream 1" + stream.metadata = { + "album": "Test Album", + "artist": ["Test Artist 1", "Test Artist 2"], + "title": "Test Title", + "artUrl": "http://localhost/test_art.jpg", + "albumArtist": [ + "Test Album Artist 1", + "Test Album Artist 2", + ], + "trackNumber": 10, + "duration": 60.0, + } + stream.meta = stream.metadata + stream.properties = { + "position": 30.0, + **stream.metadata, + } + stream.path = None + return stream + + +@pytest.fixture +def mock_stream_2() -> AsyncMock: + """Create a mock stream.""" + stream = AsyncMock(spec=Snapstream) + stream.identifier = "test_stream_2" + stream.status = "idle" + stream.name = "Test Stream 2" + stream.friendly_name = "Test Stream 2" + stream.metadata = None + stream.meta = None + stream.properties = None + stream.path = None + return stream + + +@pytest.fixture +def streams(mock_stream_1: AsyncMock, mock_stream_2: AsyncMock) -> dict[str, AsyncMock]: + """Return a dictionary of mock streams.""" + return { + mock_stream_1.identifier: mock_stream_1, + mock_stream_2.identifier: mock_stream_2, + } diff --git a/tests/components/snapcast/const.py b/tests/components/snapcast/const.py new file mode 100644 index 00000000000..0fbd5a05460 --- /dev/null +++ b/tests/components/snapcast/const.py @@ -0,0 +1,4 @@ +"""Constants for Snapcast tests.""" + +TEST_CLIENT_ENTITY_ID = "media_player.test_client_snapcast_client" +TEST_GROUP_ENTITY_ID = "media_player.test_group_snapcast_group" diff --git a/tests/components/snapcast/snapshots/test_media_player.ambr b/tests/components/snapcast/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..3e408a0f14e --- /dev/null +++ b/tests/components/snapcast/snapshots/test_media_player.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_state[media_player.test_client_1_snapcast_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_client_1_snapcast_client', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_client_1 Snapcast Client', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[media_player.test_client_1_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': '/api/media_player_proxy/media_player.test_client_1_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'test_client_1 Snapcast Client', + 'group_members': list([ + 'media_player.test_client_1_snapcast_client', + ]), + 'is_volume_muted': False, + 'latency': 6, + 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist 1, Test Artist 2', + 'media_content_type': , + 'media_duration': 60, + 'media_position': 30, + 'media_title': 'Test Title', + 'media_track': 10, + 'source': 'test_stream_1', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_client_1_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_state[media_player.test_client_2_snapcast_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_client_2_snapcast_client', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_client_2 Snapcast Client', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[media_player.test_client_2_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'test_client_2 Snapcast Client', + 'group_members': list([ + 'media_player.test_client_2_snapcast_client', + ]), + 'is_volume_muted': False, + 'latency': 6, + 'media_content_type': , + 'source': 'test_stream_2', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 1.0, + }), + 'context': , + 'entity_id': 'media_player.test_client_2_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_state[media_player.test_group_1_snapcast_group-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_1_snapcast_group', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test Group 1 Snapcast Group', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e1', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[media_player.test_group_1_snapcast_group-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': '/api/media_player_proxy/media_player.test_group_1_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'Test Group 1 Snapcast Group', + 'is_volume_muted': False, + 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist 1, Test Artist 2', + 'media_content_type': , + 'media_duration': 60, + 'media_position': 30, + 'media_title': 'Test Title', + 'media_track': 10, + 'source': 'test_stream_1', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_group_1_snapcast_group', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_state[media_player.test_group_2_snapcast_group-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_2_snapcast_group', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test Group 2 Snapcast Group', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[media_player.test_group_2_snapcast_group-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Group 2 Snapcast Group', + 'is_volume_muted': False, + 'media_content_type': , + 'source': 'test_stream_2', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.65, + }), + 'context': , + 'entity_id': 'media_player.test_group_2_snapcast_group', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index 3bdba8b4c58..5b7d30211e1 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -1,95 +1,103 @@ """Test the Snapcast module.""" import socket -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest -from homeassistant import config_entries, setup from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} - -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server") +TEST_CONNECTION = {CONF_HOST: "127.0.0.1", CONF_PORT: 1705} -async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_server: AsyncMock ) -> None: - """Test we get the form and handle errors and successful connection.""" - await setup.async_setup_component(hass, "persistent_notification", {}) + """Test the full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - # test invalid host error - with patch("snapcast.control.create_server", side_effect=socket.gaierror): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_host"} - - # test connection error - with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - # test success result = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_CONNECTION + result["flow_id"], + TEST_CONNECTION, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Snapcast" - assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} - assert len(mock_create_server.mock_calls) == 1 + assert result["data"] == {CONF_HOST: "127.0.0.1", CONF_PORT: 1705} assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +@pytest.mark.parametrize( + ("exception", "error"), + [ + (socket.gaierror, "invalid_host"), + (ConnectionRefusedError, "cannot_connect"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_server: AsyncMock, + exception: Exception, + error: str, ) -> None: - """Test config flow abort if device is already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONNECTION, - ) - entry.add_to_hass(hass) + """Test we get the form and handle errors and successful connection.""" + mock_server.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with patch("snapcast.control.create_server", side_effect=socket.gaierror): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_server.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/snapcast/test_media_player.py b/tests/components/snapcast/test_media_player.py new file mode 100644 index 00000000000..35605cb74ab --- /dev/null +++ b/tests/components/snapcast/test_media_player.py @@ -0,0 +1,229 @@ +"""Test the snapcast media player implementation.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + SERVICE_UNJOIN, + SERVICE_VOLUME_SET, +) +from homeassistant.components.snapcast.const import ATTR_MASTER, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er, issue_registry as ir + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test basic state information.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("members"), + [ + ["media_player.test_client_2_snapcast_client"], + [ + "media_player.test_client_1_snapcast_client", + "media_player.test_client_2_snapcast_client", + ], + ], +) +async def test_join( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_group_1: AsyncMock, + mock_client_2: AsyncMock, + members: list[str], +) -> None: + """Test grouping of media players through the join service.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + ATTR_GROUP_MEMBERS: members, + }, + blocking=True, + ) + mock_group_1.add_client.assert_awaited_once_with(mock_client_2.identifier) + + +async def test_unjoin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_client_1: AsyncMock, + mock_group_1: AsyncMock, +) -> None: + """Test the unjoin service removes the client from the group.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + }, + blocking=True, + ) + + mock_group_1.remove_client.assert_awaited_once_with(mock_client_1.identifier) + + +async def test_join_exception( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + mock_group_1: AsyncMock, +) -> None: + """Test join service throws an exception when trying to add a non-Snapcast client.""" + + # Create a dummy media player entity + entity_registry.async_get_or_create( + MEDIA_PLAYER_DOMAIN, + "dummy", + "media_player_1", + ) + await hass.async_block_till_done() + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_1_snapcast_client", + ATTR_GROUP_MEMBERS: ["media_player.dummy_media_player_1"], + }, + blocking=True, + ) + + # Ensure that the group did not attempt to add a non-Snapcast client + mock_group_1.add_client.assert_not_awaited() + + +async def test_legacy_join_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, +) -> None: + """Test the legacy grouping services create issues when used.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Call the legacy join service + await hass.services.async_call( + DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_2_snapcast_client", + ATTR_MASTER: "media_player.test_client_1_snapcast_client", + }, + blocking=True, + ) + + # Verify the issue is created + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + assert issue is not None + + # Clear existing issue + issue_registry.async_delete( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + + # Call legacy unjoin service + await hass.services.async_call( + DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_client_2_snapcast_client", + }, + blocking=True, + ) + + # Verify the issue is created again + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_grouping_actions", + ) + assert issue is not None + + +async def test_deprecated_group_entity_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, +) -> None: + """Test the legacy group entities create issues when used.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Call a servuce that uses a group entity + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + service_data={ + ATTR_ENTITY_ID: "media_player.test_group_1_snapcast_group", + ATTR_MEDIA_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + + # Verify the issue is created + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_group_entities", + ) + assert issue is not None diff --git a/tests/components/snmp/test_float_sensor.py b/tests/components/snmp/test_float_sensor.py index a4f6e21dad7..032a89e8be8 100644 --- a/tests/components/snmp/test_float_sensor.py +++ b/tests/components/snmp/test_float_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Opaque(value=b"\x9fx\x04=\xa4\x00\x00") with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_init.py b/tests/components/snmp/test_init.py index 0aa97dcc475..37039444aa0 100644 --- a/tests/components/snmp/test_init.py +++ b/tests/components/snmp/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import patch -from pysnmp.hlapi.asyncio import SnmpEngine -from pysnmp.hlapi.asyncio.cmdgen import lcd +from pysnmp.hlapi.v3arch.asyncio import SnmpEngine +from pysnmp.hlapi.v3arch.asyncio.cmdgen import LCD from homeassistant.components import snmp from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -16,7 +16,7 @@ async def test_async_get_snmp_engine(hass: HomeAssistant) -> None: assert isinstance(engine, SnmpEngine) engine2 = await snmp.async_get_snmp_engine(hass) assert engine is engine2 - with patch.object(lcd, "unconfigure") as mock_unconfigure: + with patch.object(LCD, "unconfigure") as mock_unconfigure: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() assert mock_unconfigure.called diff --git a/tests/components/snmp/test_integer_sensor.py b/tests/components/snmp/test_integer_sensor.py index 8e7e0f166ef..8a7d3b91138 100644 --- a/tests/components/snmp/test_integer_sensor.py +++ b/tests/components/snmp/test_integer_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Integer32(13) with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_negative_sensor.py b/tests/components/snmp/test_negative_sensor.py index 66a111b68d0..512cd536df9 100644 --- a/tests/components/snmp/test_negative_sensor.py +++ b/tests/components/snmp/test_negative_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Integer32(-13) with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_string_sensor.py b/tests/components/snmp/test_string_sensor.py index 5362e79c98d..b51fae0afe5 100644 --- a/tests/components/snmp/test_string_sensor.py +++ b/tests/components/snmp/test_string_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = OctetString("98F") with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_switch.py b/tests/components/snmp/test_switch.py index fe1c3922ff0..a70428934ac 100644 --- a/tests/components/snmp/test_switch.py +++ b/tests/components/snmp/test_switch.py @@ -27,7 +27,7 @@ async def test_snmp_integer_switch_off(hass: HomeAssistant) -> None: mock_data = Integer32(0) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) @@ -41,7 +41,7 @@ async def test_snmp_integer_switch_on(hass: HomeAssistant) -> None: mock_data = Integer32(1) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) @@ -57,7 +57,7 @@ async def test_snmp_integer_switch_unknown( mock_data = Integer32(3) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py index b4692e6f08b..417eb438143 100644 --- a/tests/components/snoo/__init__.py +++ b/tests/components/snoo/__init__.py @@ -48,7 +48,7 @@ def find_update_callback( mock: AsyncMock, serial_number: str ) -> Callable[[SnooData], Awaitable[None]]: """Find the update callback for a specific identifier.""" - for call in mock.subscribe.call_args_list: + for call in mock.start_subscribe.call_args_list: if call[0][0].serialNumber == serial_number: return call[0][1] pytest.fail(f"Callback for identifier {serial_number} not found") diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py index 2657048afb8..cd52679caf9 100644 --- a/tests/components/snoo/const.py +++ b/tests/components/snoo/const.py @@ -31,7 +31,12 @@ MOCK_SNOO_DEVICES = [ "name": "Test Snoo", "presence": {}, "presenceIoT": {}, - "awsIoT": {}, + "awsIoT": { + "awsRegion": "us-east-1", + "clientEndpoint": "z00023244d7fia4appr4b-ats.iot.us-east-1.amazonaws.com", + "clientReady": True, + "thingName": "676cbbe74529f85038b2e623_5831231335004715141_prod", + }, "lastSSID": {}, "provisionedAt": "random_time", } diff --git a/tests/components/solarlog/fixtures/solarlog_data.json b/tests/components/solarlog/fixtures/solarlog_data.json index 339ab4a4dfc..be29194a783 100644 --- a/tests/components/solarlog/fixtures/solarlog_data.json +++ b/tests/components/solarlog/fixtures/solarlog_data.json @@ -21,5 +21,10 @@ "usage": 54.8, "power_available": 45.13, "capacity": 85.5, - "last_updated": "2024-08-01T15:20:45Z" + "last_updated": "2024-08-01T15:20:45Z", + "battery_data": { + "charge_power": 1074, + "discharge_power": 0, + "level": 79 + } } diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 6aef72ebbd5..212742b82f0 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -26,6 +26,12 @@ }), 'solarlog_data': dict({ 'alternator_loss': 2.0, + 'battery_data': dict({ + 'charge_power': 1074.0, + 'discharge_power': 0.0, + 'level': 79.0, + 'voltage': 0, + }), 'capacity': 85.5, 'consumption_ac': 54.87, 'consumption_day': 5.31, diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index 8f0ee17df44..0ddccf6a193 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -341,6 +341,115 @@ 'state': '85.5', }) # --- +# name: test_all_entities[sensor.solarlog_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge level', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_level', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SolarLog Charge level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '79.0', + }) +# --- +# name: test_all_entities[sensor.solarlog_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarLog Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1074.0', + }) +# --- # name: test_all_entities[sensor.solarlog_consumption_ac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -689,6 +798,62 @@ 'state': '0.00734', }) # --- +# name: test_all_entities[sensor.solarlog_discharging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_discharging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharging power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharging_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_discharging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_discharging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarLog Discharging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_discharging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.solarlog_efficiency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index d121d5a4a12..6831e4139c2 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -1,5 +1,7 @@ """Configuration for Sonos tests.""" +from __future__ import annotations + import asyncio from collections.abc import Callable, Coroutine, Generator from copy import copy @@ -107,13 +109,31 @@ class SonosMockAlarmClock(SonosMockService): class SonosMockEvent: """Mock a sonos Event used in callbacks.""" - def __init__(self, soco, service, variables) -> None: - """Initialize the instance.""" + def __init__( + self, + soco: MockSoCo, + service: SonosMockService, + variables: dict[str, str], + zone_player_uui_ds_in_group: str | None = None, + ) -> None: + """Initialize the instance. + + Args: + soco: The mock SoCo device associated with this event. + service: The Sonos mock service that generated the event. + variables: A dictionary of event variables and their values. + zone_player_uui_ds_in_group: Optional comma-separated string of unique zone IDs in the group. + + """ self.sid = f"{soco.uid}_sub0000000001" self.seq = "0" self.timestamp = 1621000000.0 self.service = service self.variables = variables + # In Soco events of the same type may or may not have this attribute present. + # Only create the attribute if it should be present. + if zone_player_uui_ds_in_group: + self.zone_player_uui_ds_in_group = zone_player_uui_ds_in_group def increment_variable(self, var_name): """Increment the value of the var_name key in variables dict attribute. @@ -147,16 +167,26 @@ async def async_autosetup_sonos(async_setup_sonos): await async_setup_sonos() +def reset_sonos_alarms(alarm_event: SonosMockEvent) -> None: + """Reset the Sonos alarms to a known state.""" + sonos_alarms = Alarms() + sonos_alarms.alarms = {} + sonos_alarms._last_zone_used = None + sonos_alarms._last_alarm_list_version = None + sonos_alarms.last_uid = None + sonos_alarms.last_id = 0 + alarm_event.variables["alarm_list_version"] = "RINCON_test:0" + + @pytest.fixture def async_setup_sonos( - hass: HomeAssistant, config_entry: MockConfigEntry, fire_zgs_event + hass: HomeAssistant, config_entry: MockConfigEntry, fire_zgs_event, alarm_event ) -> Callable[[], Coroutine[Any, Any, None]]: """Return a coroutine to set up a Sonos integration instance on demand.""" async def _wrapper(): config_entry.add_to_hass(hass) - sonos_alarms = Alarms() - sonos_alarms.last_alarm_list_version = "RINCON_test:0" + reset_sonos_alarms(alarm_event) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() @@ -194,12 +224,26 @@ class MockSoCo(MagicMock): surround_level = 3 music_surround_level = 4 soundbar_audio_input_format = "Dolby 5.1" + factory: SoCoMockFactory | None = None @property def visible_zones(self): """Return visible zones and allow property to be overridden by device classes.""" return {self} + @property + def all_zones(self) -> set[MockSoCo]: + """Return all mock zones if a factory is set and enabled, else just self.""" + return ( + self.factory.mock_all_zones + if self.factory and self.factory.mock_all_zones + else {self} + ) + + def set_factory(self, factory: SoCoMockFactory) -> None: + """Set the factory for this mock.""" + self.factory = factory + class SoCoMockFactory: """Factory for creating SoCo Mocks.""" @@ -223,12 +267,19 @@ class SoCoMockFactory: self.alarm_clock = alarm_clock self.sonos_playlists = sonos_playlists self.sonos_queue = sonos_queue + self.mock_zones: bool = False + + @property + def mock_all_zones(self) -> set[MockSoCo] | None: + """Return a set of all mock zones, or None if not enabled.""" + return set(self.mock_list.values()) if self.mock_zones else None def cache_mock( self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A" ) -> MockSoCo: """Put a user created mock into the cache.""" mock_soco.mock_add_spec(SoCo) + mock_soco.set_factory(self) mock_soco.ip_address = ip_address if ip_address != "192.168.42.2": mock_soco.uid += f"_{ip_address}" @@ -237,9 +288,15 @@ class SoCoMockFactory: mock_soco.music_source_from_uri = SoCo.music_source_from_uri mock_soco.get_sonos_playlists.return_value = self.sonos_playlists mock_soco.get_queue.return_value = self.sonos_queue + mock_soco._player_name = name my_speaker_info = self.speaker_info.copy() my_speaker_info["zone_name"] = name my_speaker_info["uid"] = mock_soco.uid + # Generate a different MAC for the non-default speakers. + # otherwise new devices will not be created. + if ip_address != "192.168.42.2": + last_octet = ip_address.split(".")[-1] + my_speaker_info["mac_address"] = f"00-00-00-00-00-{last_octet.zfill(2)}" mock_soco.get_speaker_info = Mock(return_value=my_speaker_info) mock_soco.add_to_queue = Mock(return_value=10) mock_soco.add_uri_to_queue = Mock(return_value=10) @@ -258,7 +315,6 @@ class SoCoMockFactory: mock_soco.alarmClock = self.alarm_clock mock_soco.get_battery_info.return_value = self.battery_info - mock_soco.all_zones = {mock_soco} mock_soco.group.coordinator = mock_soco mock_soco.household_id = "test_household_id" self.mock_list[ip_address] = mock_soco @@ -804,11 +860,15 @@ def zgs_event_fixture( @pytest.fixture(name="sonos_setup_two_speakers") async def sonos_setup_two_speakers( - hass: HomeAssistant, soco_factory: SoCoMockFactory + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + alarm_event: SonosMockEvent, ) -> list[MockSoCo]: """Set up home assistant with two Sonos Speakers.""" soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + reset_sonos_alarms(alarm_event) + await async_setup_component( hass, DOMAIN, @@ -823,3 +883,62 @@ async def sonos_setup_two_speakers( ) await hass.async_block_till_done() return [soco_lr, soco_br] + + +def create_zgs_sonos_event( + fixture_file: str, + soco_1: MockSoCo, + soco_2: MockSoCo, + create_uui_ds_in_group: bool = True, +) -> SonosMockEvent: + """Create a Sonos Event for zone group state, with the option of creating the uui_ds_in_group.""" + zgs = load_fixture(fixture_file, DOMAIN) + variables = {} + variables["ZoneGroupState"] = zgs + # Sonos does not always send this variable with zgs events + if create_uui_ds_in_group: + variables["zone_player_uui_ds_in_group"] = f"{soco_1.uid},{soco_2.uid}" + zone_player_uui_ds_in_group = ( + f"{soco_1.uid},{soco_2.uid}" if create_uui_ds_in_group else None + ) + return SonosMockEvent( + soco_1, soco_1.zoneGroupTopology, variables, zone_player_uui_ds_in_group + ) + + +def group_speakers(coordinator: MockSoCo, group_member: MockSoCo) -> None: + """Generate events to group two speakers together.""" + event = create_zgs_sonos_event( + "zgs_group.xml", coordinator, group_member, create_uui_ds_in_group=True + ) + coordinator.zoneGroupTopology.subscribe.return_value._callback(event) + group_member.zoneGroupTopology.subscribe.return_value._callback(event) + + +def ungroup_speakers(coordinator: MockSoCo, group_member: MockSoCo) -> None: + """Generate events to ungroup two speakers.""" + event = create_zgs_sonos_event( + "zgs_two_single.xml", coordinator, group_member, create_uui_ds_in_group=False + ) + coordinator.zoneGroupTopology.subscribe.return_value._callback(event) + group_member.zoneGroupTopology.subscribe.return_value._callback(event) + + +def create_rendering_control_event( + soco: MockSoCo, +) -> SonosMockEvent: + """Create a Sonos Event for speaker rendering control.""" + variables = { + "dialog_level": 1, + "speech_enhance_enable": 1, + "surround_level": 6, + "music_surround_level": 4, + "audio_delay": 0, + "audio_delay_left_rear": 0, + "audio_delay_right_rear": 0, + "night_mode": 0, + "surround_enabled": 1, + "surround_mode": 1, + "height_channel_level": 1, + } + return SonosMockEvent(soco, soco.renderingControl, variables) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 1bc8baff752..c1b98b2ec60 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,7 +1,6 @@ """Tests for the Sonos config flow.""" import asyncio -from datetime import timedelta import logging from unittest.mock import Mock, PropertyMock, patch @@ -330,29 +329,24 @@ async def test_async_poll_manual_hosts_5( soco_2.renderingControl = Mock() soco_2.renderingControl.GetVolume = Mock() speaker_2_activity = SpeakerActivity(hass, soco_2) - with patch( - "homeassistant.components.sonos.DISCOVERY_INTERVAL" - ) as mock_discovery_interval: - # Speed up manual discovery interval so second iteration runs sooner - mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) - with caplog.at_level(logging.DEBUG): - caplog.clear() + with caplog.at_level(logging.DEBUG): + caplog.clear() - await _setup_hass(hass) + await _setup_hass(hass) - assert "media_player.bedroom" in entity_registry.entities - assert "media_player.living_room" in entity_registry.entities + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=0.5)) - await hass.async_block_till_done() - await asyncio.gather( - *[speaker_1_activity.event.wait(), speaker_2_activity.event.wait()] - ) - assert speaker_1_activity.call_count == 1 - assert speaker_2_activity.call_count == 1 - assert "Activity on Living Room" in caplog.text - assert "Activity on Bedroom" in caplog.text + async_fire_time_changed(hass, dt_util.utcnow() + DISCOVERY_INTERVAL) + await hass.async_block_till_done() + await asyncio.gather( + *[speaker_1_activity.event.wait(), speaker_2_activity.event.wait()] + ) + assert speaker_1_activity.call_count == 1 + assert speaker_2_activity.call_count == 1 + assert "Activity on Living Room" in caplog.text + assert "Activity on Bedroom" in caplog.text await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index b15d7698e05..41b18750fd4 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -33,14 +33,20 @@ from homeassistant.components.sonos.const import ( SOURCE_TV, ) from homeassistant.components.sonos.media_player import ( + ATTR_ALARM_ID, + ATTR_ENABLED, + ATTR_INCLUDE_LINKED_ZONES, + ATTR_VOLUME, LONG_SERVICE_TIMEOUT, SERVICE_GET_QUEUE, SERVICE_RESTORE, SERVICE_SNAPSHOT, + SERVICE_UPDATE_ALARM, VOLUME_INCREMENT, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_TIME, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -54,7 +60,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import area_registry as ar, entity_registry as er from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, CONNECTION_UPNP, @@ -83,11 +89,15 @@ async def test_device_registry( assert reg_device.manufacturer == "Sonos" assert reg_device.name == "Zone A" # Default device provides battery info, area should not be suggested - assert reg_device.suggested_area is None + assert reg_device.area_id is None async def test_device_registry_not_portable( - hass: HomeAssistant, device_registry: DeviceRegistry, async_setup_sonos, soco + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: DeviceRegistry, + async_setup_sonos, + soco, ) -> None: """Test non-portable sonos device registered in the device registry to ensure area suggested.""" soco.get_battery_info.return_value = {} @@ -97,7 +107,7 @@ async def test_device_registry_not_portable( identifiers={("sonos", "RINCON_test")} ) assert reg_device is not None - assert reg_device.suggested_area == "Zone A" + assert reg_device.area_id == area_registry.async_get_area_by_name("Zone A").id async def test_entity_basic( @@ -1261,3 +1271,67 @@ async def test_media_source_list( """Test the mapping between the speaker model name and source_list.""" state = hass.states.get("media_player.zone_a") assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == source_list + + +async def test_service_update_alarm( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, +) -> None: + """Test updating an alarm.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_ALARM, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_ALARM_ID: 14, + ATTR_TIME: "07:15:00", + ATTR_VOLUME: 0.25, + ATTR_INCLUDE_LINKED_ZONES: True, + ATTR_ENABLED: True, + }, + blocking=True, + ) + + assert soco.alarmClock.UpdateAlarm.call_count == 1 + assert soco.alarmClock.UpdateAlarm.call_args.args[0] == [ + ("ID", "14"), + ("StartLocalTime", "07:15:00"), + ("Duration", "02:00:00"), + ("Recurrence", "DAILY"), + ("Enabled", "1"), + ("RoomUUID", "RINCON_test"), + ("ProgramURI", "x-rincon-buzzer:0"), + ("ProgramMetaData", ""), + ("PlayMode", "SHUFFLE_NOREPEAT"), + ("Volume", 25), + ("IncludeLinkedZones", "1"), + ] + + +async def test_service_update_alarm_dne( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, +) -> None: + """Test updating an alarm that does not exist.""" + + with pytest.raises( + ServiceValidationError, + match="Alarm 99 does not exist and cannot be updated", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_ALARM, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_ALARM_ID: 99, + ATTR_TIME: "07:15:00", + ATTR_VOLUME: 0.25, + ATTR_INCLUDE_LINKED_ZONES: True, + ATTR_ENABLED: True, + }, + blocking=True, + ) + assert soco.alarmClock.UpdateAlarm.call_count == 0 diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py new file mode 100644 index 00000000000..ada48de21f3 --- /dev/null +++ b/tests/components/sonos/test_select.py @@ -0,0 +1,189 @@ +"""Tests for the Sonos select platform.""" + +import logging +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.sonos.const import ( + ATTR_DIALOG_LEVEL, + MODEL_SONOS_ARC_ULTRA, + SCAN_INTERVAL, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import create_rendering_control_event + +from tests.common import async_fire_time_changed + +SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_speech_enhancement" + + +@pytest.fixture(name="platform_select", autouse=True) +async def platform_binary_sensor_fixture(): + """Patch Sonos to only load select platform.""" + with patch("homeassistant.components.sonos.PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.parametrize( + ("level", "result"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + level: int, + result: str, +) -> None: + """Test dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = level + + await async_setup_sonos() + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == result + + +async def test_select_dialog_invalid_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving an invalid level from the speaker.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 10 + + with caplog.at_level(logging.WARNING): + await async_setup_sonos() + assert "Invalid option 10 for dialog_level" in caplog.text + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("result", "option"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level_set( + hass: HomeAssistant, + async_setup_sonos, + soco, + speaker_info: dict[str, str], + result: int, + option: str, +) -> None: + """Test setting dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: SELECT_DIALOG_LEVEL_ENTITY, ATTR_OPTION: option}, + blocking=True, + ) + + assert soco.dialog_level == result + + +async def test_select_dialog_level_only_arc_ultra( + hass: HomeAssistant, + async_setup_sonos, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test the dialog level select is only created for Sonos Arc Ultra.""" + + speaker_info["model_name"] = "Sonos S1" + await async_setup_sonos() + + assert SELECT_DIALOG_LEVEL_ENTITY not in entity_registry.entities + + +async def test_select_dialog_level_event( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test dialog level select entity updated by event.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + event = create_rendering_control_event(soco) + event.variables[ATTR_DIALOG_LEVEL] = 3 + soco.renderingControl.subscribe.return_value._callback(event) + await hass.async_block_till_done(wait_background_tasks=True) + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "high" + + +async def test_select_dialog_level_poll( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity updated by poll when subscription fails.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + soco.dialog_level = 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "max" diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 45068c01bc0..f98fd9a4fed 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -1,20 +1,35 @@ """Tests for the Sonos battery sensor platform.""" +from collections.abc import Callable, Coroutine from datetime import timedelta +from typing import Any from unittest.mock import PropertyMock, patch import pytest from soco.exceptions import NotSupportedException from homeassistant.components.sensor import SCAN_INTERVAL +from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOURCE +from homeassistant.components.sonos.sensor import ( + HA_POWER_SOURCE_BATTERY, + HA_POWER_SOURCE_CHARGING_BASE, + HA_POWER_SOURCE_USB, + SensorDeviceClass, +) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from homeassistant.util import dt as dt_util -from .conftest import SonosMockEvent +from .conftest import MockSoCo, SonosMockEvent from tests.common import async_fire_time_changed @@ -42,8 +57,10 @@ async def test_entity_registry_supported( assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities assert "binary_sensor.zone_a_charging" in entity_registry.entities + assert "sensor.zone_a_power_source" in entity_registry.entities +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_battery_attributes( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: @@ -60,6 +77,71 @@ async def test_battery_attributes( power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "SONOS_CHARGING_RING" ) + power_source = entity_registry.entities["sensor.zone_a_power_source"] + power_source_state = hass.states.get(power_source.entity_id) + assert power_source_state.state == HA_POWER_SOURCE_CHARGING_BASE + assert power_source_state.attributes.get("device_class") == SensorDeviceClass.ENUM + assert power_source_state.attributes.get("options") == [ + HA_POWER_SOURCE_BATTERY, + HA_POWER_SOURCE_CHARGING_BASE, + HA_POWER_SOURCE_USB, + ] + result = translation.async_translate_state( + hass, + power_source_state.state, + Platform.SENSOR, + DOMAIN, + power_source.translation_key, + None, + ) + assert result == "Charging base" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_power_source_unknown_state( + hass: HomeAssistant, + async_setup_sonos: Callable[[], Coroutine[Any, Any, None]], + soco: MockSoCo, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test bad value for power source.""" + soco.get_battery_info.return_value = { + "Level": 100, + "PowerSource": "BAD_POWER_SOURCE", + } + + with caplog.at_level("WARNING"): + await async_setup_sonos() + assert "Unknown power source" in caplog.text + assert "BAD_POWER_SOURCE" in caplog.text + assert "Zone A" in caplog.text + + power_source = entity_registry.entities["sensor.zone_a_power_source"] + power_source_state = hass.states.get(power_source.entity_id) + assert power_source_state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_power_source_none( + hass: HomeAssistant, + async_setup_sonos: Callable[[], Coroutine[Any, Any, None]], + soco: MockSoCo, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test none value for power source.""" + soco.get_battery_info.return_value = { + "Level": 100, + "PowerSource": None, + } + + await async_setup_sonos() + + power_source = entity_registry.entities["sensor.zone_a_power_source"] + power_source_state = hass.states.get(power_source.entity_id) + assert power_source_state.state == STATE_UNAVAILABLE + async def test_battery_on_s1( hass: HomeAssistant, diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index 8f83ce2f814..a94a03b95a0 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -1,53 +1,217 @@ """Tests for Sonos services.""" +import asyncio +from contextlib import asynccontextmanager +import logging +import re from unittest.mock import Mock, patch import pytest -from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, SERVICE_JOIN +from homeassistant.components.media_player import ( + DOMAIN as MP_DOMAIN, + SERVICE_JOIN, + SERVICE_UNJOIN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from .conftest import MockSoCo, group_speakers, ungroup_speakers async def test_media_player_join( - hass: HomeAssistant, async_autosetup_sonos, config_entry: MockConfigEntry + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, ) -> None: - """Test join service.""" - valid_entity_id = "media_player.zone_a" - mocked_entity_id = "media_player.mocked" + """Test joining two speakers together.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] - # Ensure an error is raised if the entity is unknown - with pytest.raises(HomeAssistantError): + # After dispatching the join to the speakers, the integration waits for the + # group to be updated before returning. To simulate this we will dispatch + # a ZGS event to group the speaker. This event is + # triggered by the firing of the join_complete_event in the join mock. + join_complete_event = asyncio.Event() + + def mock_join(*args, **kwargs) -> None: + hass.loop.call_soon_threadsafe(join_complete_event.set) + + soco_bedroom.join = Mock(side_effect=mock_join) + + with caplog.at_level(logging.WARNING): + caplog.clear() await hass.services.async_call( MP_DOMAIN, SERVICE_JOIN, - {"entity_id": valid_entity_id, "group_members": mocked_entity_id}, + { + "entity_id": "media_player.living_room", + "group_members": ["media_player.bedroom"], + }, + blocking=False, + ) + await join_complete_event.wait() + # Fire the ZGS event to update the speaker grouping as the join method is waiting + # for the speakers to be regrouped. + group_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + # Code logs warning messages if the join is not successful, so we check + # that no warning messages were logged. + assert len(caplog.records) == 0 + # The API joins the group members to the entity_id speaker. + assert soco_bedroom.join.call_count == 1 + assert soco_bedroom.join.call_args[0][0] == soco_living_room + assert soco_living_room.join.call_count == 0 + + +async def test_media_player_join_bad_entity( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], +) -> None: + """Test error handling of joining with a bad entity.""" + + # Ensure an error is raised if the entity is unknown + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_JOIN, + { + "entity_id": "media_player.living_room", + "group_members": "media_player.bad_entity", + }, blocking=True, ) + assert "media_player.bad_entity" in str(excinfo.value) - # Ensure SonosSpeaker.join_multi is called if entity is found - mocked_speaker = Mock() - mock_entity_id_mappings = {mocked_entity_id: mocked_speaker} +async def test_media_player_join_entity_no_speaker( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + entity_registry: er.EntityRegistry, +) -> None: + """Test error handling of joining with no associated speaker.""" + + bad_media_player = entity_registry.async_get_or_create( + "media_player", "demo", "1234" + ) + + # Ensure an error is raised if the entity does not have a speaker + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_JOIN, + { + "entity_id": "media_player.living_room", + "group_members": bad_media_player.entity_id, + }, + blocking=True, + ) + assert bad_media_player.entity_id in str(excinfo.value) + + +@asynccontextmanager +async def instant_timeout(*args, **kwargs) -> None: + """Mock a timeout error.""" + raise TimeoutError + # This is never reached, but is needed to satisfy the asynccontextmanager + yield # pylint: disable=unreachable + + +async def test_media_player_join_timeout( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test joining of two speakers with timeout error.""" + + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + expected = ( + "Timeout while waiting for Sonos player to join the " + "group ['Living Room: Living Room, Bedroom']" + ) with ( - patch.dict( - config_entry.runtime_data.entity_id_mappings, - mock_entity_id_mappings, - ), patch( - "homeassistant.components.sonos.speaker.SonosSpeaker.join_multi" - ) as mock_join_multi, + "homeassistant.components.sonos.speaker.asyncio.timeout", instant_timeout + ), + pytest.raises(HomeAssistantError, match=re.escape(expected)), ): await hass.services.async_call( MP_DOMAIN, SERVICE_JOIN, - {"entity_id": valid_entity_id, "group_members": mocked_entity_id}, + { + "entity_id": "media_player.living_room", + "group_members": ["media_player.bedroom"], + }, + blocking=True, + ) + assert soco_bedroom.join.call_count == 1 + assert soco_bedroom.join.call_args[0][0] == soco_living_room + assert soco_living_room.join.call_count == 0 + + +async def test_media_player_unjoin( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unjoing two speaker.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + # First group the speakers together + group_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now that the speaker are joined, test unjoining + unjoin_complete_event = asyncio.Event() + + def mock_unjoin(*args, **kwargs): + hass.loop.call_soon_threadsafe(unjoin_complete_event.set) + + soco_bedroom.unjoin = Mock(side_effect=mock_unjoin) + + with caplog.at_level(logging.WARNING): + caplog.clear() + await hass.services.async_call( + MP_DOMAIN, + SERVICE_UNJOIN, + {"entity_id": "media_player.bedroom"}, + blocking=False, + ) + await unjoin_complete_event.wait() + # Fire the ZGS event to ungroup the speakers as the unjoin method is waiting + # for the speakers to be ungrouped. + ungroup_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(caplog.records) == 0 + assert soco_bedroom.unjoin.call_count == 1 + assert soco_living_room.unjoin.call_count == 0 + + +async def test_media_player_unjoin_already_unjoined( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unjoining when already unjoined.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + with caplog.at_level(logging.WARNING): + caplog.clear() + await hass.services.async_call( + MP_DOMAIN, + SERVICE_UNJOIN, + {"entity_id": "media_player.bedroom"}, blocking=True, ) - found_speaker = config_entry.runtime_data.entity_id_mappings[valid_entity_id] - mock_join_multi.assert_called_with( - hass, config_entry, found_speaker, [mocked_speaker] - ) + assert len(caplog.records) == 0 + # Should not have called unjoin, since the speakers are already unjoined. + assert soco_bedroom.unjoin.call_count == 0 + assert soco_living_room.unjoin.call_count == 0 diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 468b848dfb5..cdb7be15589 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -13,12 +13,11 @@ from homeassistant.components.sonos.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .conftest import MockSoCo, SonosMockEvent +from .conftest import MockSoCo, SonosMockEvent, group_speakers, ungroup_speakers from tests.common import ( MockConfigEntry, async_fire_time_changed, - load_fixture, load_json_value_fixture, ) @@ -81,22 +80,6 @@ async def test_subscription_creation_fails( assert speaker._subscriptions -def _create_zgs_sonos_event( - fixture_file: str, soco_1: MockSoCo, soco_2: MockSoCo, create_uui_ds: bool = True -) -> SonosMockEvent: - """Create a Sonos Event for zone group state, with the option of creating the uui_ds_in_group.""" - zgs = load_fixture(fixture_file, DOMAIN) - variables = {} - variables["ZoneGroupState"] = zgs - # Sonos does not always send this variable with zgs events - if create_uui_ds: - variables["zone_player_uui_ds_in_group"] = f"{soco_1.uid},{soco_2.uid}" - event = SonosMockEvent(soco_1, soco_1.zoneGroupTopology, variables) - if create_uui_ds: - event.zone_player_uui_ds_in_group = f"{soco_1.uid},{soco_2.uid}" - return event - - def _create_avtransport_sonos_event( fixture_file: str, soco: MockSoCo ) -> SonosMockEvent: @@ -142,11 +125,8 @@ async def test_zgs_event_group_speakers( soco_br.play.reset_mock() # Test 2 - Group the speakers, living room is the coordinator - event = _create_zgs_sonos_event( - "zgs_group.xml", soco_lr, soco_br, create_uui_ds=True - ) - soco_lr.zoneGroupTopology.subscribe.return_value._callback(event) - soco_br.zoneGroupTopology.subscribe.return_value._callback(event) + group_speakers(soco_lr, soco_br) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("media_player.living_room") assert state.attributes["group_members"] == [ @@ -168,11 +148,8 @@ async def test_zgs_event_group_speakers( soco_br.play.reset_mock() # Test 3 - Ungroup the speakers - event = _create_zgs_sonos_event( - "zgs_two_single.xml", soco_lr, soco_br, create_uui_ds=False - ) - soco_lr.zoneGroupTopology.subscribe.return_value._callback(event) - soco_br.zoneGroupTopology.subscribe.return_value._callback(event) + ungroup_speakers(soco_lr, soco_br) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("media_player.living_room") assert state.attributes["group_members"] == ["media_player.living_room"] @@ -206,11 +183,7 @@ async def test_zgs_avtransport_group_speakers( soco_br.play.reset_mock() # Test 2- Send a zgs event to return living room to its own coordinator - event = _create_zgs_sonos_event( - "zgs_two_single.xml", soco_lr, soco_br, create_uui_ds=False - ) - soco_lr.zoneGroupTopology.subscribe.return_value._callback(event) - soco_br.zoneGroupTopology.subscribe.return_value._callback(event) + ungroup_speakers(soco_lr, soco_br) await hass.async_block_till_done(wait_background_tasks=True) # Call should route to the living room await _media_play(hass, "media_player.living_room") diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 04457ee95c7..f2dd3478a90 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -6,13 +6,19 @@ from unittest.mock import patch import pytest -from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER +from homeassistant.components.sonos import DOMAIN +from homeassistant.components.sonos.const import ( + DATA_SONOS_DISCOVERY_MANAGER, + MODEL_SONOS_ARC_ULTRA, +) from homeassistant.components.sonos.switch import ( ATTR_DURATION, ATTR_ID, ATTR_INCLUDE_LINKED_ZONES, ATTR_PLAY_MODE, ATTR_RECURRENCE, + ATTR_SPEECH_ENHANCEMENT, + ATTR_SPEECH_ENHANCEMENT_ENABLED, ATTR_VOLUME, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -26,10 +32,17 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .conftest import MockSoCo, SonosMockEvent +from .conftest import ( + MockSoCo, + SoCoMockFactory, + SonosMockEvent, + SonosMockService, + create_rendering_control_event, +) from tests.common import async_fire_time_changed @@ -142,6 +155,49 @@ async def test_switch_attributes( assert touch_controls_state.state == STATE_ON +@pytest.mark.parametrize( + ("model", "attribute"), + [ + ("Sonos One SL", ATTR_SPEECH_ENHANCEMENT), + (MODEL_SONOS_ARC_ULTRA.lower(), ATTR_SPEECH_ENHANCEMENT_ENABLED), + ], +) +async def test_switch_speech_enhancement( + hass: HomeAssistant, + async_setup_sonos, + soco: MockSoCo, + speaker_info: dict[str, str], + entity_registry: er.EntityRegistry, + model: str, + attribute: str, +) -> None: + """Tests the speech enhancement switch and attribute substitution for different models.""" + entity_id = "switch.zone_a_speech_enhancement" + speaker_info["model_name"] = model + soco.get_speaker_info.return_value = speaker_info + setattr(soco, attribute, True) + await async_setup_sonos() + switch = entity_registry.entities[entity_id] + state = hass.states.get(switch.entity_id) + assert state.state == STATE_ON + + event = create_rendering_control_event(soco) + event.variables[attribute] = False + soco.renderingControl.subscribe.return_value._callback(event) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(switch.entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert getattr(soco, attribute) is True + + @pytest.mark.parametrize( ("service", "expected_result"), [ @@ -211,3 +267,73 @@ async def test_alarm_create_delete( assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" not in entity_registry.entities + + +async def test_alarm_change_device( + hass: HomeAssistant, + alarm_clock: SonosMockService, + alarm_clock_extended: SonosMockService, + alarm_event: SonosMockEvent, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + soco_factory: SoCoMockFactory, +) -> None: + """Test Sonos Alarm being moved to a different speaker. + + This test simulates a scenario where an alarm is created on one speaker + and then moved to another speaker. It checks that the entity is correctly + created on the new speaker and removed from the old one. + """ + + # Create the alarm on the soco_lr speaker + soco_factory.mock_zones = True + soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + alarm_dict = copy(alarm_clock.ListAlarms.return_value) + alarm_dict["CurrentAlarmList"] = alarm_dict["CurrentAlarmList"].replace( + "RINCON_test", f"{soco_lr.uid}" + ) + alarm_dict["CurrentAlarmListVersion"] = "RINCON_test:900" + soco_lr.alarmClock.ListAlarms.return_value = alarm_dict + soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "media_player": { + "interface_addr": "127.0.0.1", + "hosts": ["10.10.10.1", "10.10.10.2"], + } + } + }, + ) + await hass.async_block_till_done() + + entity_id = "switch.sonos_alarm_14" + + # Verify the alarm is created on the soco_lr speaker + assert entity_id in entity_registry.entities + entity = entity_registry.async_get(entity_id) + device = device_registry.async_get(entity.device_id) + assert device.name == soco_lr.get_speaker_info()["zone_name"] + + # Simulate the alarm being moved to the soco_br speaker + alarm_update = copy(alarm_clock_extended.ListAlarms.return_value) + alarm_update["CurrentAlarmList"] = alarm_update["CurrentAlarmList"].replace( + "RINCON_test", f"{soco_br.uid}" + ) + alarm_clock.ListAlarms.return_value = alarm_update + + # Update the alarm_list_version so it gets processed. + alarm_event.variables["alarm_list_version"] = "RINCON_test:1000" + alarm_update["CurrentAlarmListVersion"] = alarm_event.increment_variable( + "alarm_list_version" + ) + + alarm_clock.subscribe.return_value.callback(event=alarm_event) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entity_id in entity_registry.entities + alarm_14 = entity_registry.async_get(entity_id) + device = device_registry.async_get(alarm_14.device_id) + assert device.name == soco_br.get_speaker_info()["zone_name"] diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index 67d4eac3960..9efc453f855 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -8,8 +8,8 @@ import pytest from spotifyaio.models import ( Album, Artist, - ArtistResponse, Devices, + FollowedArtistResponse, NewReleasesResponse, NewReleasesResponseInner, PlaybackState, @@ -138,7 +138,7 @@ def mock_spotify() -> Generator[AsyncMock]: getattr(client, method).return_value = obj.from_json( load_fixture(fixture, DOMAIN) ) - client.get_followed_artists.return_value = ArtistResponse.from_json( + client.get_followed_artists.return_value = FollowedArtistResponse.from_json( load_fixture("followed_artists.json", DOMAIN) ).artists.items client.get_new_releases.return_value = NewReleasesResponse.from_json( diff --git a/tests/components/spotify/snapshots/test_diagnostics.ambr b/tests/components/spotify/snapshots/test_diagnostics.ambr index 0ac375d18e3..8866fa45055 100644 --- a/tests/components/spotify/snapshots/test_diagnostics.ambr +++ b/tests/components/spotify/snapshots/test_diagnostics.ambr @@ -125,6 +125,15 @@ 'tracks': dict({ 'items': list([ dict({ + 'added_at': '2015-01-15T12:39:22+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '2pANdqPvxInB0YvcDiw4ko', @@ -182,6 +191,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:40:03+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '6nlfkk5GoXRL1nktlATNsy', @@ -239,6 +257,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:22:30+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '4hnqM0JK4CM1phwfq1Ldyz', @@ -296,6 +323,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:40:35+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '2usKFntxa98WHMcyW6xJBz', @@ -353,6 +389,15 @@ }), }), dict({ + 'added_at': '2015-01-15T12:41:10+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/jmperezperez', + }), + 'href': 'https://api.spotify.com/v1/users/jmperezperez', + 'uri': 'spotify:user:jmperezperez', + 'user_id': 'jmperezperez', + }), 'track': dict({ 'album': dict({ 'album_id': '0ivM6kSawaug0j3tZVusG2', @@ -410,6 +455,15 @@ }), }), dict({ + 'added_at': '2024-11-28T11:20:58+00:00', + 'added_by': dict({ + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/user/1112264649', + }), + 'href': 'https://api.spotify.com/v1/users/1112264649', + 'uri': 'spotify:user:1112264649', + 'user_id': '1112264649', + }), 'track': dict({ 'description': 'Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy', 'duration_ms': 3690161, diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index a3adf05f5f0..2dd9403d53f 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -30,7 +30,6 @@ from homeassistant.components.squeezebox.const import ( ) from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import format_mac from tests.common import MockConfigEntry @@ -44,7 +43,7 @@ SERVER_UUIDS = [ "12345678-1234-1234-1234-123456789012", "87654321-4321-4321-4321-210987654321", ] -TEST_MAC = ["aa:bb:cc:dd:ee:ff", "ff:ee:dd:cc:bb:aa"] +TEST_MAC = ["aa:bb:cc:dd:ee:ff", "de:ad:be:ef:de:ad", "ff:ee:dd:cc:bb:aa"] TEST_PLAYER_NAME = "Test Player" TEST_SERVER_NAME = "Test Server" TEST_ALARM_ID = "1" @@ -52,14 +51,13 @@ FAKE_VALID_ITEM_ID = "1234" FAKE_INVALID_ITEM_ID = "4321" FAKE_IP = "42.42.42.42" -FAKE_MAC = "deadbeefdead" -FAKE_UUID = "deadbeefdeadbeefbeefdeafbeef42" +FAKE_UUID = "deadbeefdeadbeefbeefdeafbddeef42" FAKE_PORT = 9000 FAKE_VERSION = "42.0" FAKE_QUERY_RESPONSE = { - STATUS_QUERY_UUID: FAKE_UUID, - STATUS_QUERY_MAC: FAKE_MAC, + STATUS_QUERY_UUID: SERVER_UUIDS[0], + STATUS_QUERY_MAC: TEST_MAC[2], STATUS_QUERY_VERSION: FAKE_VERSION, STATUS_SENSOR_RESCAN: 1, STATUS_SENSOR_LASTSCAN: 0, @@ -268,10 +266,12 @@ def player_factory() -> MagicMock: def mock_pysqueezebox_player(uuid: str) -> MagicMock: """Mock a Lyrion Media Server player.""" + assert uuid with patch( "homeassistant.components.squeezebox.Player", autospec=True ) as mock_player: mock_player.async_browse = AsyncMock(side_effect=mock_async_browse) + mock_player.async_query = AsyncMock(return_value=MagicMock()) mock_player.generate_image_url_from_track_id = MagicMock( return_value="http://lms.internal:9000/html/images/favorites.png" ) @@ -294,6 +294,8 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.image_url = None mock_player.model = "SqueezeLite" mock_player.creator = "Ralph Irving & Adrian Smith" + mock_player.model_type = None + mock_player.firmware = None mock_player.alarms_enabled = True return mock_player @@ -310,7 +312,7 @@ def lms_factory(player_factory: MagicMock) -> MagicMock: @pytest.fixture def lms(player_factory: MagicMock) -> MagicMock: """Mock a Lyrion Media Server with one mock player attached.""" - return mock_pysqueezebox_server(player_factory, 1, uuid=TEST_MAC[0]) + return mock_pysqueezebox_server(player_factory, 1, uuid=SERVER_UUIDS[0]) def mock_pysqueezebox_server( @@ -323,9 +325,11 @@ def mock_pysqueezebox_server( mock_lms.uuid = uuid mock_lms.name = TEST_SERVER_NAME - mock_lms.async_query = AsyncMock(return_value={"uuid": format_mac(uuid)}) + mock_lms.async_query = AsyncMock( + return_value={"uuid": uuid, "mac": TEST_MAC[2]} + ) mock_lms.async_status = AsyncMock( - return_value={"uuid": format_mac(uuid), "version": FAKE_VERSION} + return_value={"uuid": uuid, "version": FAKE_VERSION} ) return mock_lms @@ -428,6 +432,6 @@ async def configured_players( hass: HomeAssistant, config_entry: MockConfigEntry, lms_factory: MagicMock ) -> list[MagicMock]: """Fixture mocking calls to two pysqueezebox Players from a configured squeezebox.""" - lms = lms_factory(2, uuid=SERVER_UUIDS[0]) + lms = lms_factory(3, uuid=SERVER_UUIDS[0]) await configure_squeezebox_media_player_platform(hass, config_entry, lms) return await lms.async_get_players() diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr new file mode 100644 index 00000000000..afd90d026de --- /dev/null +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Ralph Irving & Adrian Smith', + 'model': 'SqueezeLite', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- +# name: test_device_registry_server_merged + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + '12345678-1234-1234-1234-123456789012', + ), + tuple( + 'squeezebox', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', + 'model': 'Lyrion Music Server/SqueezeLite', + 'model_id': 'LMS', + 'name': '1.2.3.4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 4bb00dea5c6..183b5ca767f 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -1,41 +1,4 @@ # serializer version: 1 -# name: test_device_registry - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'squeezebox', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Ralph Irving & Adrian Smith', - 'model': 'SqueezeLite', - 'model_id': None, - 'name': 'Test Player', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }) -# --- # name: test_entity_registry[media_player.test_player-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/squeezebox/snapshots/test_switch.ambr b/tests/components/squeezebox/snapshots/test_switch.ambr index 275fc26baa7..6d53eb38021 100644 --- a/tests/components/squeezebox/snapshots/test_switch.ambr +++ b/tests/components/squeezebox/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entity_registry[switch.test_player_alarm_1-entry] +# name: test_entity_registry[switch.none_alarm_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,7 +12,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.test_player_alarm_1', + 'entity_id': 'switch.none_alarm_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -34,21 +34,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[switch.test_player_alarm_1-state] +# name: test_entity_registry[switch.none_alarm_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'alarm_id': '1', - 'friendly_name': 'Test Player Alarm (1)', + 'friendly_name': 'Alarm (1)', }), 'context': , - 'entity_id': 'switch.test_player_alarm_1', + 'entity_id': 'switch.none_alarm_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_entity_registry[switch.test_player_alarms_enabled-entry] +# name: test_entity_registry[switch.none_alarms_enabled-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,7 +61,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.test_player_alarms_enabled', + 'entity_id': 'switch.none_alarms_enabled', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -83,13 +83,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[switch.test_player_alarms_enabled-state] +# name: test_entity_registry[switch.none_alarms_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Player Alarms enabled', + 'friendly_name': 'Alarms enabled', }), 'context': , - 'entity_id': 'switch.test_player_alarms_enabled', + 'entity_id': 'switch.none_alarms_enabled', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/squeezebox/test_button.py b/tests/components/squeezebox/test_button.py index 16ced65be61..53c4e9ef626 100644 --- a/tests/components/squeezebox/test_button.py +++ b/tests/components/squeezebox/test_button.py @@ -14,7 +14,7 @@ async def test_squeezebox_press( await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_player_preset_1"}, + {ATTR_ENTITY_ID: "button.none_preset_1"}, blocking=True, ) diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index f70782b13da..5cb7e19abb5 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -1,10 +1,16 @@ """Test squeezebox initialization.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + +from .conftest import TEST_MAC from tests.common import MockConfigEntry @@ -82,3 +88,27 @@ async def test_init_missing_uuid( mock_async_query.assert_called_once_with( "serverstatus", "-", "-", "prefs:libraryname" ) + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_player: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) + assert reg_device is not None + assert reg_device == snapshot + + +async def test_device_registry_server_merged( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_players: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) + assert reg_device is not None + assert reg_device == snapshot diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index f71a7db23ba..6e3e5be0459 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -68,7 +68,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow @@ -82,18 +81,6 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_device_registry( - hass: HomeAssistant, - device_registry: DeviceRegistry, - configured_player: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test squeezebox device registered in the device registry.""" - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) - assert reg_device is not None - assert reg_device == snapshot - - async def test_entity_registry( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -158,30 +145,21 @@ async def test_squeezebox_player_rediscovery( assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE -async def test_squeezebox_turn_on( - hass: HomeAssistant, configured_player: MagicMock +@pytest.mark.parametrize( + ("service", "state"), + [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)], +) +async def test_squeezebox_turn_on_off( + hass: HomeAssistant, configured_player: MagicMock, service: str, state: bool ) -> None: """Test turn on service call.""" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_power.assert_called_once_with(True) - - -async def test_squeezebox_turn_off( - hass: HomeAssistant, configured_player: MagicMock -) -> None: - """Test turn off service call.""" - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "media_player.test_player"}, - blocking=True, - ) - configured_player.async_set_power.assert_called_once_with(False) + configured_player.async_set_power.assert_called_once_with(state) async def test_squeezebox_state( @@ -523,7 +501,10 @@ async def test_squeezebox_play_media_with_announce_volume_invalid( hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int ) -> None: """Test play service call with announce and volume zero.""" - with pytest.raises(ServiceValidationError): + with pytest.raises( + ServiceValidationError, + match="announce_volume must be a number greater than 0 and less than or equal to 1", + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -784,9 +765,7 @@ async def test_squeezebox_call_query( }, blocking=True, ) - configured_player.async_query.assert_called_once_with( - "test_command", "param1", "param2" - ) + configured_player.async_query.assert_called_with("test_command", "param1", "param2") async def test_squeezebox_call_method( @@ -803,9 +782,7 @@ async def test_squeezebox_call_method( }, blocking=True, ) - configured_player.async_query.assert_called_once_with( - "test_command", "param1", "param2" - ) + configured_player.async_query.assert_called_with("test_command", "param1", "param2") async def test_squeezebox_invalid_state( diff --git a/tests/components/squeezebox/test_switch.py b/tests/components/squeezebox/test_switch.py index e4c8c3b5e4d..2e6e9bafeb0 100644 --- a/tests/components/squeezebox/test_switch.py +++ b/tests/components/squeezebox/test_switch.py @@ -34,13 +34,13 @@ async def test_switch_state( freezer: FrozenDateTimeFactory, ) -> None: """Test the state of the switch.""" - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "on" + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}").state == "on" mock_alarms_player.alarms[0]["enabled"] = False freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "off" + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}").state == "off" async def test_switch_deleted( @@ -49,13 +49,13 @@ async def test_switch_deleted( freezer: FrozenDateTimeFactory, ) -> None: """Test detecting switch deleted.""" - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "on" + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}").state == "on" mock_alarms_player.alarms = [] freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}") is None + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}") is None async def test_turn_on( @@ -66,7 +66,7 @@ async def test_turn_on( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: f"switch.test_player_alarm_{TEST_ALARM_ID}"}, + {CONF_ENTITY_ID: f"switch.none_alarm_{TEST_ALARM_ID}"}, blocking=True, ) mock_alarms_player.async_update_alarm.assert_called_once_with( @@ -82,7 +82,7 @@ async def test_turn_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: f"switch.test_player_alarm_{TEST_ALARM_ID}"}, + {CONF_ENTITY_ID: f"switch.none_alarm_{TEST_ALARM_ID}"}, blocking=True, ) mock_alarms_player.async_update_alarm.assert_called_once_with( @@ -97,14 +97,14 @@ async def test_alarms_enabled_state( ) -> None: """Test the alarms enabled switch.""" - assert hass.states.get("switch.test_player_alarms_enabled").state == "on" + assert hass.states.get("switch.none_alarms_enabled").state == "on" mock_alarms_player.alarms_enabled = False freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_player_alarms_enabled").state == "off" + assert hass.states.get("switch.none_alarms_enabled").state == "off" async def test_alarms_enabled_turn_on( @@ -115,7 +115,7 @@ async def test_alarms_enabled_turn_on( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: "switch.test_player_alarms_enabled"}, + {CONF_ENTITY_ID: "switch.none_alarms_enabled"}, blocking=True, ) mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(True) @@ -129,7 +129,7 @@ async def test_alarms_enabled_turn_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: "switch.test_player_alarms_enabled"}, + {CONF_ENTITY_ID: "switch.none_alarms_enabled"}, blocking=True, ) mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(False) diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index c11045a2eb2..2312daa8c52 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -10,7 +10,7 @@ from homeassistant.components import statistics from homeassistant.components.statistics import DOMAIN from homeassistant.components.statistics.config_flow import StatisticsConfigFlowHandler from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -85,6 +85,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -173,7 +174,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(statistics_config_entry.entry_id) @@ -188,9 +189,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -201,6 +200,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the statistics config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the statistics config entry is removed + assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the statistics config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -217,7 +263,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -234,7 +280,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -261,7 +310,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -276,7 +325,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the entity is no longer linked to the source device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id is None + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -309,7 +362,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert statistics_config_entry.entry_id not in sensor_device_2.config_entries @@ -326,11 +379,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is moved to the other device + # Check that the entity is linked to the other device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert statistics_config_entry.entry_id in sensor_device_2.config_entries + assert statistics_config_entry.entry_id not in sensor_device_2.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -355,7 +412,7 @@ async def test_async_handle_source_entity_new_entity_id( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -373,12 +430,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the statistics config entry is updated with the new entity ID assert statistics_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes statistics config entry from device.""" + + statistics_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": sensor_entity_entry.entity_id, + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=1, + minor_version=1, + ) + statistics_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + assert statistics_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + assert statistics_config_entry.version == 1 + assert statistics_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": "sensor.test", + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 21df0146ef5..e882909878a 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -6,7 +6,7 @@ from asyncio import Event as AsyncioEvent from collections.abc import Sequence from datetime import datetime, timedelta import statistics -from threading import Event +from threading import Event as ThreadingEvent from typing import Any from unittest.mock import patch @@ -42,8 +42,9 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -54,6 +55,9 @@ VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] VALUES_NUMERIC = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] VALUES_NUMERIC_LINEAR = [1, 2, 3, 4, 5, 6, 7, 8, 9] +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} + async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry @@ -249,7 +253,22 @@ async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: assert "age_coverage_ratio" not in state.attributes -async def test_sensor_state_reported(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values", "attributes"), + [ + # Fires last reported events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A1, A1, A1, A1, A1, A1, A1, A1]), + # Fires state change events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A2, A1, A2, A1, A2, A1, A2, A1]), + ], +) +async def test_sensor_state_updated_reported( + hass: HomeAssistant, + values: list[float], + attributes: list[dict[str, Any]], + force_update: bool, +) -> None: """Test the behavior of the sensor with a sequence of identical values. Forced updates no longer make a difference, since the statistics are now reacting not @@ -258,7 +277,6 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: This fixes problems with time based averages and some other functions that behave differently when repeating values are reported. """ - repeating_values = [18, 0, 0, 0, 0, 0, 0, 0, 9] assert await async_setup_component( hass, "sensor", @@ -267,14 +285,7 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: { "platform": "statistics", "name": "test_normal", - "entity_id": "sensor.test_monitored_normal", - "state_characteristic": "mean", - "sampling_size": 20, - }, - { - "platform": "statistics", - "name": "test_force", - "entity_id": "sensor.test_monitored_force", + "entity_id": "sensor.test_monitored", "state_characteristic": "mean", "sampling_size": 20, }, @@ -283,27 +294,19 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value in repeating_values: + for value, attribute in zip(values, attributes, strict=True): hass.states.async_set( - "sensor.test_monitored_normal", + "sensor.test_monitored", str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - ) - hass.states.async_set( - "sensor.test_monitored_force", - str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - force_update=True, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} | attribute, + force_update=force_update, ) await hass.async_block_till_done() - state_normal = hass.states.get("sensor.test_normal") - state_force = hass.states.get("sensor.test_force") - assert state_normal and state_force - assert state_normal.state == str(round(sum(repeating_values) / 9, 2)) - assert state_force.state == str(round(sum(repeating_values) / 9, 2)) - assert state_normal.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) - assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + state = hass.states.get("sensor.test_normal") + assert state + assert state.state == str(round(sum(values) / 9, 2)) + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) async def test_sampling_boundaries_given(hass: HomeAssistant) -> None: @@ -1739,7 +1742,7 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) # some synchronisation is needed to prevent that loading from the database finishes too soon # we want this to take long enough to be able to try to add a value BEFORE loading is done state_changes_during_period_called_evt = AsyncioEvent() - state_changes_during_period_stall_evt = Event() + state_changes_during_period_stall_evt = ThreadingEvent() real_state_changes_during_period = history.state_changes_during_period def mock_state_changes_during_period(*args, **kwargs): @@ -1785,12 +1788,49 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) assert float(hass.states.get("sensor.test").state) == pytest.approx(4.5) -async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values_attributes_and_times", "expected_states"), + [ + ( + # Fires last reported events + [(5.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (5.0, A1, 1)], + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], + ), + ( # Fires state change events + [(5.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (5.0, A1, 1)], + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], + ), + ( + # Fires last reported events + [(10.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (10.0, A1, 1)], + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], + ), + ( # Fires state change events + [(10.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (10.0, A1, 1)], + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], + ), + ], +) +async def test_average_linear_unevenly_timed( + hass: HomeAssistant, + force_update: bool, + values_attributes_and_times: list[tuple[float, dict[str, Any], float]], + expected_states: list[str], +) -> None: """Test the average_linear state characteristic with unevenly distributed values. This also implicitly tests the correct timing of repeating values. """ - values_and_times = [[5.0, 2], [10.0, 1], [10.0, 1], [10.0, 2], [5.0, 1]] + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event( + hass, "sensor.test_sensor_average_linear", _capture_event + ) current_time = dt_util.utcnow() @@ -1814,23 +1854,20 @@ async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value_and_time in values_and_times: + for value, extra_attributes, time in values_attributes_and_times: hass.states.async_set( "sensor.test_monitored", - str(value_and_time[0]), - {ATTR_UNIT_OF_MEASUREMENT: DEGREE}, + str(value), + {ATTR_UNIT_OF_MEASUREMENT: DEGREE} | extra_attributes, + force_update=force_update, ) - current_time += timedelta(seconds=value_and_time[1]) + current_time += timedelta(seconds=time) freezer.move_to(current_time) await hass.async_block_till_done() - state = hass.states.get("sensor.test_sensor_average_linear") - assert state is not None - assert state.state == "8.33", ( - "value mismatch for characteristic 'sensor/average_linear' - " - f"assert {state.state} == 8.33" - ) + await hass.async_block_till_done() + assert [event.data["new_state"].state for event in events] == expected_states async def test_sensor_unit_gets_removed(hass: HomeAssistant) -> None: diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index dd7f2a7bbc3..0f127ba767a 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,26 +1,18 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator -from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.components.stookwijzer.services import Forecast from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -class Forecast(TypedDict): - """Typed Stookwijzer forecast dict.""" - - datetime: Required[str] - advice: str | None - final: bool | None - - @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/stookwijzer/snapshots/test_services.ambr b/tests/components/stookwijzer/snapshots/test_services.ambr new file mode 100644 index 00000000000..d5124219d32 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_services.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_service_get_forecast + dict({ + 'forecast': tuple( + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ), + }) +# --- diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py new file mode 100644 index 00000000000..d7ec036d6e4 --- /dev/null +++ b/tests/components/stookwijzer/test_services.py @@ -0,0 +1,69 @@ +"""Tests for the Stookwijzer services.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.stookwijzer.const import DOMAIN, SERVICE_GET_FORECAST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_service_get_forecast( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Stookwijzer forecast service.""" + + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_entry_not_loaded( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when entry is not loaded.""" + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_integration_not_found( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when integration not in registry.""" + with pytest.raises( + ServiceValidationError, match='Integration "stookwijzer" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index c96b7d9427f..eb554f2cf19 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -230,8 +230,8 @@ async def test_stream_timeout( playlist_response = await http_client.get(parsed_url.path) assert playlist_response.status == HTTPStatus.OK - # Wait a minute - future = dt_util.utcnow() + timedelta(minutes=1) + # Wait 40 seconds + future = dt_util.utcnow() + timedelta(seconds=40) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -241,8 +241,8 @@ async def test_stream_timeout( stream_worker_sync.resume() - # Wait 5 minutes - future = dt_util.utcnow() + timedelta(minutes=5) + # Wait 2 minutes + future = dt_util.utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 9d29191289e..005c14b7458 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -87,5 +87,7 @@ def mock_suez_client(recorder_mock: Recorder) -> Generator[AsyncMock]: ) suez_client.fetch_aggregated_data.return_value = result - suez_client.get_price.return_value = PriceResult("4.74") + suez_client.get_price.return_value = PriceResult( + "OK", {"price": 4.74}, "Price is 4.74" + ) yield suez_client diff --git a/tests/components/swiss_public_transport/test_service.py b/tests/components/swiss_public_transport/test_service.py index 135fb07fda8..b65ffc12de1 100644 --- a/tests/components/swiss_public_transport/test_service.py +++ b/tests/components/swiss_public_transport/test_service.py @@ -12,7 +12,6 @@ import pytest from voluptuous import error as vol_er from homeassistant.components.swiss_public_transport.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONF_DESTINATION, CONF_START, @@ -22,6 +21,7 @@ from homeassistant.components.swiss_public_transport.const import ( SERVICE_FETCH_CONNECTIONS, ) from homeassistant.components.swiss_public_transport.helper import unique_id_from_config +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 5c5737804e1..89c84b1ed34 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -125,7 +125,12 @@ async def test_get_condition_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( @@ -155,7 +160,12 @@ async def test_get_condition_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } conditions = await async_get_device_automations( diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 81f8a93611d..a642bb44825 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -125,7 +125,12 @@ async def test_get_trigger_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( @@ -155,7 +160,12 @@ async def test_get_trigger_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( diff --git a/tests/components/switch_as_x/__init__.py b/tests/components/switch_as_x/__init__.py index 2addb832462..dbf1afa54ac 100644 --- a/tests/components/switch_as_x/__init__.py +++ b/tests/components/switch_as_x/__init__.py @@ -1,6 +1,11 @@ """The tests for Switch as X platforms.""" +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.fan import FanEntityFeature +from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.lock import LockState +from homeassistant.components.siren import SirenEntityFeature +from homeassistant.components.valve import ValveEntityFeature from homeassistant.const import STATE_CLOSED, STATE_OFF, STATE_ON, STATE_OPEN, Platform PLATFORMS_TO_TEST = ( @@ -12,6 +17,15 @@ PLATFORMS_TO_TEST = ( Platform.VALVE, ) +CAPABILITY_MAP = { + Platform.COVER: None, + Platform.FAN: {}, + Platform.LIGHT: {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.ONOFF]}, + Platform.LOCK: None, + Platform.SIREN: None, + Platform.VALVE: None, +} + STATE_MAP = { False: { Platform.COVER: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, @@ -30,3 +44,12 @@ STATE_MAP = { Platform.VALVE: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, }, } + +SUPPORTED_FEATURE_MAP = { + Platform.COVER: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + Platform.FAN: FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF, + Platform.LIGHT: 0, + Platform.LOCK: 0, + Platform.SIREN: SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF, + Platform.VALVE: ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, +} diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 2c87b0e3a92..a201cb258d6 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -25,12 +25,12 @@ from homeassistant.const import ( EntityCategory, Platform, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component -from . import PLATFORMS_TO_TEST +from . import CAPABILITY_MAP, PLATFORMS_TO_TEST, SUPPORTED_FEATURE_MAP from tests.common import MockConfigEntry @@ -79,6 +79,22 @@ def switch_as_x_config_entry( return config_entry +def track_entity_registry_actions( + hass: HomeAssistant, entity_id: str +) -> list[er.EventEntityRegistryUpdatedData]: + """Track entity registry actions for an entity.""" + events = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_unregistered_uuid( hass: HomeAssistant, target_domain: str @@ -222,7 +238,7 @@ async def test_device_registry_config_entry_1( assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) - assert switch_as_x_config_entry.entry_id in device_entry.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries events = [] @@ -304,7 +320,7 @@ async def test_device_registry_config_entry_2( assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) - assert switch_as_x_config_entry.entry_id in device_entry.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries events = [] @@ -386,7 +402,7 @@ async def test_device_registry_config_entry_3( assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) - assert switch_as_x_config_entry.entry_id in device_entry.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries device_entry_2 = device_registry.async_get(device_entry_2.id) assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries @@ -413,7 +429,7 @@ async def test_device_registry_config_entry_3( device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id not in device_entry.config_entries device_entry_2 = device_registry.async_get(device_entry_2.id) - assert switch_as_x_config_entry.entry_id in device_entry_2.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries # Check that the switch_as_x config entry is not removed assert switch_as_x_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -1083,11 +1099,31 @@ async def test_restore_expose_settings( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_migrate( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test migration.""" - # Setup the config entry + # Switch config entry, device and entity + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + switch_entity_entry = entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=device_entry.id, + original_name="ABC", + suggested_object_id="test", + ) + assert switch_entity_entry.entity_id == "switch.test" + + # Switch_as_x config entry, device and entity config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -1100,9 +1136,37 @@ async def test_migrate( minor_version=1, ) config_entry.add_to_hass(hass) + device_registry.async_update_device( + device_entry.id, add_config_entry_id=config_entry.entry_id + ) + switch_as_x_entity_entry = entity_registry.async_get_or_create( + target_domain, + "switch_as_x", + config_entry.entry_id, + capabilities=CAPABILITY_MAP[target_domain], + config_entry=config_entry, + device_id=device_entry.id, + original_name="ABC", + suggested_object_id="abc", + supported_features=SUPPORTED_FEATURE_MAP[target_domain], + ) + entity_registry.async_update_entity_options( + switch_as_x_entity_entry.entity_id, + DOMAIN, + {"entity_id": "switch.test", "invert": False}, + ) + + events = track_entity_registry_actions(hass, switch_as_x_entity_entry.entity_id) + + # Setup the switch_as_x config entry assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert set(entity_registry.entities) == { + switch_entity_entry.entity_id, + switch_as_x_entity_entry.entity_id, + } + # Check migration was successful and added invert option assert config_entry.state is ConfigEntryState.LOADED assert config_entry.options == { @@ -1117,6 +1181,20 @@ async def test_migrate( assert hass.states.get(f"{target_domain}.abc") is not None assert entity_registry.async_get(f"{target_domain}.abc") is not None + # Entity removed from device to prevent deletion, then added back to device + assert events == [ + { + "action": "update", + "changes": {"device_id": device_entry.id}, + "entity_id": switch_as_x_entity_entry.entity_id, + }, + { + "action": "update", + "changes": {"device_id": None}, + "entity_id": switch_as_x_entity_entry.entity_id, + }, + ] + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_migrate_from_future( diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 5dca8167e05..d64ee2d7a73 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -859,3 +859,143 @@ AIR_PURIFIER_TABLE_VOC_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + +EVAPORATIVE_HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Evaporative Humidifier", + manufacturer_data={ + 2409: b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"#\x00\x00\x15\x1c\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Evaporative Humidifier", + manufacturer_data={ + 2409: b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"#\x00\x00\x15\x1c\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Evaporative Humidifier"), + time=0, + connectable=True, + tx_power=-127, +) + + +BULB_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Bulb", + manufacturer_data={ + 2409: b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"u\x00d", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Bulb", + manufacturer_data={ + 2409: b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"u\x00d", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Bulb"), + time=0, + connectable=True, + tx_power=-127, +) + + +CEILING_LIGHT_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Ceiling Light", + manufacturer_data={ + 2409: b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3\xa4", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"q\x00", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Ceiling Light", + manufacturer_data={ + 2409: b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3$", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"q\x00", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Ceiling Light"), + time=0, + connectable=True, + tx_power=-127, +) + + +STRIP_LIGHT_3_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Strip Light 3", + manufacturer_data={ + 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb1" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Strip Light 3", + manufacturer_data={ + 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb1" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Strip Light 3"), + time=0, + connectable=True, + tx_power=-127, +) + + +FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Floor Lamp", + manufacturer_data={ + 2409: b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb0" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Floor Lamp", + manufacturer_data={ + 2409: b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb0" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Floor Lamp"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_humidifier.py b/tests/components/switchbot/test_humidifier.py index fa9efac0bfd..6718fe763a8 100644 --- a/tests/components/switchbot/test_humidifier.py +++ b/tests/components/switchbot/test_humidifier.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import HUMIDIFIER_SERVICE_INFO +from . import EVAPORATIVE_HUMIDIFIER_SERVICE_INFO, HUMIDIFIER_SERVICE_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -173,3 +173,89 @@ async def test_exception_handling_humidifier_service( {**service_data, ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_TURN_ON, {}, "turn_on"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_target_humidity"), + (SERVICE_SET_MODE, {ATTR_MODE: "sleep"}, "set_mode"), + ], +) +async def test_evaporative_humidifier_services( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test evaporative humidifier services with proper parameters.""" + inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="evaporative_humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier", + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_TURN_ON, {}, "turn_on"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_target_humidity"), + (SERVICE_SET_MODE, {ATTR_MODE: "sleep"}, "set_mode"), + ], +) +async def test_evaporative_humidifier_services_with_exception( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test exception handling for evaporative humidifier services.""" + inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="evaporative_humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + patch_target = f"homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier.{mock_method}" + + with patch( + patch_target, + new=AsyncMock(side_effect=SwitchbotOperationError("Operation failed")), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, + match="An error occurred while performing the action: Operation failed", + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index 957d56411da..718d7aecf96 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -5,12 +5,13 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from switchbot import ColorMode as switchbotColorMode from switchbot.devices.device import SwitchbotOperationError +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -20,75 +21,265 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import WOSTRIP_SERVICE_INFO +from . import ( + BULB_SERVICE_INFO, + CEILING_LIGHT_SERVICE_INFO, + FLOOR_LAMP_SERVICE_INFO, + STRIP_LIGHT_3_SERVICE_INFO, + WOSTRIP_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info - -@pytest.mark.parametrize( - ( - "service", - "service_data", - "mock_method", - "expected_args", - "color_modes", - "color_mode", - ), +COMMON_PARAMETERS = ( + "service", + "service_data", + "mock_method", + "expected_args", +) +TURN_ON_PARAMETERS = ( + SERVICE_TURN_ON, + {}, + "turn_on", + {}, +) +TURN_OFF_PARAMETERS = ( + SERVICE_TURN_OFF, + {}, + "turn_off", + {}, +) +SET_BRIGHTNESS_PARAMETERS = ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + (round(128 / 255 * 100),), +) +SET_RGB_PARAMETERS = ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128, ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + (round(128 / 255 * 100), 255, 0, 0), +) +SET_COLOR_TEMP_PARAMETERS = ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + (round(128 / 255 * 100), 4000), +) +BULB_PARAMETERS = ( + COMMON_PARAMETERS, [ - ( - SERVICE_TURN_OFF, - {}, - "turn_off", - (), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_RGB_PARAMETERS, + SET_COLOR_TEMP_PARAMETERS, ( SERVICE_TURN_ON, - {}, - "turn_on", - (), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_BRIGHTNESS: 128}, - "set_brightness", - (round(128 / 255 * 100),), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_RGB_COLOR: (255, 0, 0)}, - "set_rgb", - (round(255 / 255 * 100), 255, 0, 0), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_COLOR_TEMP_KELVIN: 4000}, - "set_color_temp", - (100, 4000), - {switchbotColorMode.COLOR_TEMP}, - switchbotColorMode.COLOR_TEMP, + {ATTR_EFFECT: "breathing"}, + "set_effect", + ("breathing",), ), ], ) -async def test_light_strip_services( +CEILING_LIGHT_PARAMETERS = ( + COMMON_PARAMETERS, + [ + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_COLOR_TEMP_PARAMETERS, + ], +) +STRIP_LIGHT_PARAMETERS = ( + COMMON_PARAMETERS, + [ + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_RGB_PARAMETERS, + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "halloween"}, + "set_effect", + ("halloween",), + ), + ], +) +FLOOR_LAMP_PARAMETERS = ( + COMMON_PARAMETERS, + [ + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_RGB_PARAMETERS, + SET_COLOR_TEMP_PARAMETERS, + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "halloween"}, + "set_effect", + ("halloween",), + ), + ], +) + + +@pytest.mark.parametrize(*BULB_PARAMETERS) +async def test_bulb_services( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry], service: str, service_data: dict, mock_method: str, expected_args: Any, - color_modes: set | None, - color_mode: switchbotColorMode | None, ) -> None: - """Test all SwitchBot light strip services with proper parameters.""" + """Test all SwitchBot bulb services.""" + inject_bluetooth_service_info(hass, BULB_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="bulb") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotBulb", + **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize(*BULB_PARAMETERS) +async def test_bulb_services_exception( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot bulb services with exception.""" + inject_bluetooth_service_info(hass, BULB_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="bulb") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotBulb", + **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize(*CEILING_LIGHT_PARAMETERS) +async def test_ceiling_light_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot ceiling light services.""" + inject_bluetooth_service_info(hass, CEILING_LIGHT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="ceiling_light") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotCeilingLight", + **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize(*CEILING_LIGHT_PARAMETERS) +async def test_ceiling_light_services_exception( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot ceiling light services with exception.""" + inject_bluetooth_service_info(hass, CEILING_LIGHT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="ceiling_light") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotCeilingLight", + **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize(*STRIP_LIGHT_PARAMETERS) +async def test_strip_light_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot strip light services.""" inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) entry = mock_entry_factory(sensor_type="light_strip") @@ -99,10 +290,89 @@ async def test_light_strip_services( with patch.multiple( "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", - color_modes=color_modes, - color_mode=color_mode, - update=AsyncMock(return_value=None), **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize(*STRIP_LIGHT_PARAMETERS) +async def test_strip_light_services_exception( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot strip light services with exception.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", + **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), + ], +) +@pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) +async def test_floor_lamp_services( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot floor lamp services.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", + **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -118,78 +388,35 @@ async def test_light_strip_services( @pytest.mark.parametrize( - ("exception", "error_message"), + ("sensor_type", "service_info"), [ - ( - SwitchbotOperationError("Operation failed"), - "An error occurred while performing the action: Operation failed", - ), + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), ], ) -@pytest.mark.parametrize( - ("service", "service_data", "mock_method", "color_modes", "color_mode"), - [ - ( - SERVICE_TURN_ON, - {}, - "turn_on", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_OFF, - {}, - "turn_off", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_BRIGHTNESS: 128}, - "set_brightness", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_RGB_COLOR: (255, 0, 0)}, - "set_rgb", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_COLOR_TEMP_KELVIN: 4000}, - "set_color_temp", - {switchbotColorMode.COLOR_TEMP}, - switchbotColorMode.COLOR_TEMP, - ), - ], -) -async def test_exception_handling_light_service( +@pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) +async def test_floor_lamp_services_exception( hass: HomeAssistant, - mock_entry_factory: Callable[[str], MockConfigEntry], + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, service: str, service_data: dict, mock_method: str, - color_modes: set | None, - color_mode: switchbotColorMode | None, - exception: Exception, - error_message: str, + expected_args: Any, ) -> None: - """Test exception handling for light service with exception.""" - inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + """Test all SwitchBot floor lamp services with exception.""" + inject_bluetooth_service_info(hass, service_info) - entry = mock_entry_factory(sensor_type="light_strip") + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) entry.add_to_hass(hass) entity_id = "light.test_name" - + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" with patch.multiple( - "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", - color_modes=color_modes, - color_mode=color_mode, - update=AsyncMock(return_value=None), + "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index db37f3f98dd..645eb5d1ab3 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.switchbot.const import ( DOMAIN, ) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, @@ -23,6 +24,7 @@ from homeassistant.setup import async_setup_component from . import ( CIRCULATOR_FAN_SERVICE_INFO, + EVAPORATIVE_HUMIDIFIER_SERVICE_INFO, HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, @@ -318,13 +320,12 @@ async def test_hub2_sensor(hass: HomeAssistant) -> None: light_level_sensor_attrs = light_level_sensor.attributes assert light_level_sensor.state == "4" assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" - assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" - light_level_sensor = hass.states.get("sensor.test_name_illuminance") - light_level_sensor_attrs = light_level_sensor.attributes - assert light_level_sensor.state == "30" - assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" - assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" + illuminance_sensor = hass.states.get("sensor.test_name_illuminance") + illuminance_sensor_attrs = illuminance_sensor.attributes + assert illuminance_sensor.state == "30" + assert illuminance_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" + assert illuminance_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") rssi_sensor_attrs = rssi_sensor.attributes @@ -472,7 +473,6 @@ async def test_hub3_sensor(hass: HomeAssistant) -> None: light_level_sensor_attrs = light_level_sensor.attributes assert light_level_sensor.state == "3" assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" - assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" assert light_level_sensor_attrs[ATTR_STATE_CLASS] == "measurement" illuminance_sensor = hass.states.get("sensor.test_name_illuminance") @@ -484,3 +484,61 @@ async def test_hub3_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_evaporative_humidifier_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for evaporative humidifier.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "evaporative_humidifier", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier.update", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 4 + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "25.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + water_level_sensor = hass.states.get("sensor.test_name_water_level") + water_level_sensor_attrs = water_level_sensor.attributes + assert water_level_sensor.state == "medium" + assert water_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Water level" + assert water_level_sensor_attrs[ATTR_DEVICE_CLASS] == "enum" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index 42fe3e4f543..b0d1c29f4a9 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -1,5 +1,7 @@ """Tests for the SwitchBot Cloud integration.""" +from switchbot_api import Device + from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant @@ -21,3 +23,20 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: await hass.async_block_till_done() return entry + + +AIR_PURIFIER_INFO = Device( + version="V1.0", + deviceId="air-purifier-id-1", + deviceName="air-purifier-1", + deviceType="Air Purifier Table PM2.5", + hubDeviceId="test-hub-id", +) + +CIRCULATOR_FAN_INFO = Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", +) diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py index 09c953da06b..c38e3e1264e 100644 --- a/tests/components/switchbot_cloud/conftest.py +++ b/tests/components/switchbot_cloud/conftest.py @@ -30,3 +30,22 @@ def mock_get_status(): """Mock get_status.""" with patch.object(SwitchBotAPI, "get_status") as mock_get_status: yield mock_get_status + + +@pytest.fixture(scope="package", autouse=True) +def mock_after_command_refresh(): + """Mock after command refresh.""" + with patch( + "homeassistant.components.switchbot_cloud.const.AFTER_COMMAND_REFRESH", 0 + ): + yield + + +@pytest.fixture(scope="package", autouse=True) +def mock_after_command_refresh_for_cover(): + """Mock after command refresh.""" + with patch( + "homeassistant.components.switchbot_cloud.const.COVER_ENTITY_AFTER_COMMAND_REFRESH", + 0, + ): + yield diff --git a/tests/components/switchbot_cloud/fixtures/air_purifier_status.json b/tests/components/switchbot_cloud/fixtures/air_purifier_status.json new file mode 100644 index 00000000000..b490c1c966c --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/air_purifier_status.json @@ -0,0 +1,8 @@ +{ + "version": "V2.3", + "power": "ON", + "mode": 2, + "deviceId": "air-purifier-id-1", + "deviceType": "Air Purifier Table PM2.5", + "hubDeviceId": "test-hub-id" +} diff --git a/tests/components/switchbot_cloud/snapshots/test_fan.ambr b/tests/components/switchbot_cloud/snapshots/test_fan.ambr new file mode 100644 index 00000000000..e5139527aca --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_fan.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_air_purifier[fan.air_purifier_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'normal', + 'auto', + 'sleep', + 'pet', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.air_purifier_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'air_purifier', + 'unique_id': 'air-purifier-id-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_air_purifier[fan.air_purifier_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'air-purifier-1', + 'preset_mode': 'auto', + 'preset_modes': list([ + 'normal', + 'auto', + 'sleep', + 'pet', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.air_purifier_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_cover.py b/tests/components/switchbot_cloud/test_cover.py new file mode 100644 index 00000000000..0d0daf1bd7b --- /dev/null +++ b/tests/components/switchbot_cloud/test_cover.py @@ -0,0 +1,457 @@ +"""Test for the switchbot_cloud Cover.""" + +from unittest.mock import patch + +import pytest +from switchbot_api import ( + BlindTiltCommands, + CommonCommands, + CurtainCommands, + Device, + RollerShadeCommands, +) + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_cover_set_attributes_normal( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test cover set_attributes normal.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Roller Shade", + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.return_value = {"slidePosition": 100, "direction": "up"} + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_CLOSED + + +@pytest.mark.parametrize( + "device_model", + [ + "Roller Shade", + "Blind Tilt", + ], +) +async def test_cover_set_attributes_position_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_model +) -> None: + """Test cover_set_attributes position is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.side_effect = [{"direction": "up"}, {"direction": "up"}] + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "device_model", + [ + "Roller Shade", + "Blind Tilt", + ], +) +async def test_cover_set_attributes_coordinator_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_model +) -> None: + """Test cover set_attributes coordinator is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.return_value = None + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +async def test_curtain_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test curtain features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Curtain", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.ON, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.OFF, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CurtainCommands.PAUSE, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"position": 50, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CurtainCommands.SET_POSITION, "command", "0,ff,50" + ) + + +async def test_blind_tilt_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test blind_tilt features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Blind Tilt", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + {"slidePosition": 95, "direction": "up"}, + {"slidePosition": 95, "direction": "up"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.FULLY_OPEN, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.CLOSE_UP, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"tilt_position": 25, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.SET_POSITION, "command", "up;25" + ) + + +async def test_blind_tilt_features_close_down( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test blind tilt features close_down.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Blind Tilt", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + {"slidePosition": 25, "direction": "down"}, + {"slidePosition": 25, "direction": "down"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.CLOSE_DOWN, "command", "default" + ) + + +async def test_roller_shade_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test roller shade features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Roller Shade", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "0" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "100" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"position": 50, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "50" + ) + + +async def test_cover_set_attributes_coordinator_is_none_for_garage_door( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test cover set_attributes coordinator is none for garage_door.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + cover_id = "cover.cover_1" + mock_get_status.return_value = None + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +async def test_garage_door_features_close( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test garage door features close.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "doorStatus": 1, + }, + { + "doorStatus": 1, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.OFF, "command", "default" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_CLOSED + + +async def test_garage_door_features_open( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test garage_door features open cover.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "doorStatus": 0, + }, + { + "doorStatus": 0, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.ON, "command", "default" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN diff --git a/tests/components/switchbot_cloud/test_fan.py b/tests/components/switchbot_cloud/test_fan.py new file mode 100644 index 00000000000..9852096511a --- /dev/null +++ b/tests/components/switchbot_cloud/test_fan.py @@ -0,0 +1,263 @@ +"""Test for the Switchbot Battery Circulator Fan.""" + +from unittest.mock import patch + +import pytest +import switchbot_api +from switchbot_api import Device, SwitchBotAPI +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + SERVICE_TURN_ON, +) +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import AIR_PURIFIER_INFO, CIRCULATOR_FAN_INFO, configure_integration + +from tests.common import async_load_json_object_fixture, snapshot_platform + + +@pytest.mark.parametrize( + ("device_info", "entry_id"), + [ + (AIR_PURIFIER_INFO, "fan.air_purifier_1"), + (CIRCULATOR_FAN_INFO, "fan.battery_fan_1"), + ], +) +async def test_coordinator_data_is_none( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + device_info: Device, + entry_id: str, +) -> None: + """Test coordinator data is none.""" + mock_list_devices.return_value = [device_info] + mock_get_status.side_effect = [None] + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + state = hass.states.get(entry_id) + + assert state.state == STATE_UNKNOWN + + +async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None: + """Test turning on the fan.""" + mock_list_devices.return_value = [ + CIRCULATOR_FAN_INFO, + ] + mock_get_status.side_effect = [ + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_OFF + + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turning off the fan.""" + mock_list_devices.return_value = [ + CIRCULATOR_FAN_INFO, + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +async def test_set_percentage( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set percentage.""" + mock_list_devices.return_value = [ + CIRCULATOR_FAN_INFO, + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "5"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 5}, + blocking=True, + ) + mock_send_command.assert_called() + + +async def test_set_preset_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set preset mode.""" + mock_list_devices.return_value = [ + CIRCULATOR_FAN_INFO, + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "baby", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "baby"}, + blocking=True, + ) + mock_send_command.assert_called_once() + + +async def test_air_purifier( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test air purifier.""" + + mock_list_devices.return_value = [AIR_PURIFIER_INFO] + mock_get_status.return_value = await async_load_json_object_fixture( + hass, "air_purifier_status.json", DOMAIN + ) + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.FAN]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_call_args"), + [ + ( + "turn_on", + {}, + ( + "air-purifier-id-1", + switchbot_api.CommonCommands.ON, + "command", + "default", + ), + ), + ( + "turn_off", + {}, + ( + "air-purifier-id-1", + switchbot_api.CommonCommands.OFF, + "command", + "default", + ), + ), + ( + "set_preset_mode", + {"preset_mode": "sleep"}, + ( + "air-purifier-id-1", + switchbot_api.AirPurifierCommands.SET_MODE, + "command", + {"mode": 3}, + ), + ), + ], +) +async def test_air_purifier_controller( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + service_data: dict, + expected_call_args: tuple, +) -> None: + """Test controlling the air purifier with mocked delay.""" + mock_list_devices.return_value = [AIR_PURIFIER_INFO] + mock_get_status.return_value = {"power": "OFF", "mode": 2} + + await configure_integration(hass) + fan_id = "fan.air_purifier_1" + + with ( + patch.object(SwitchBotAPI, "send_command") as mocked_send_command, + ): + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: fan_id}, + blocking=True, + ) + + mocked_send_command.assert_awaited_once_with(*expected_call_args) diff --git a/tests/components/switchbot_cloud/test_light.py b/tests/components/switchbot_cloud/test_light.py new file mode 100644 index 00000000000..e4f39c0d530 --- /dev/null +++ b/tests/components/switchbot_cloud/test_light.py @@ -0,0 +1,300 @@ +"""Test for the Switchbot Light Entity.""" + +from unittest.mock import patch + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [None] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_UNKNOWN + + +async def test_strip_light_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test strip light turn off.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "off", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + # state = hass.states.get(entity_id) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + + +async def test_rgbww_light_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test rgbww light turn_off.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light 3", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "off", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + + +async def test_strip_light_turn_on( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test strip light turn on.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "on", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "brightness": 99}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "rgb_color": (255, 246, 158)}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "color_temp_kelvin": 3333}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + +async def test_rgbww_light_turn_on( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test rgbww light turn on.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light 3", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "on", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "color_temp_kelvin": 2800}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "brightness": 99}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "rgb_color": (255, 246, 158)}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON diff --git a/tests/components/switchbot_cloud/test_vacuum.py b/tests/components/switchbot_cloud/test_vacuum.py new file mode 100644 index 00000000000..daa52f4f183 --- /dev/null +++ b/tests/components/switchbot_cloud/test_vacuum.py @@ -0,0 +1,522 @@ +"""Test for the switchbot_cloud vacuum.""" + +from unittest.mock import patch + +from switchbot_api import ( + Device, + VacuumCleanerV2Commands, + VacuumCleanerV3Commands, + VacuumCleanMode, + VacuumCommands, +) + +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.components.switchbot_cloud.const import VACUUM_FAN_SPEED_QUIET +from homeassistant.components.vacuum import ( + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + None, + ] + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_UNKNOWN + + +async def test_k10_plus_set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K10 plus set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.POW_LEVEL, "command", "0" + ) + + +async def test_k10_plus_return_to_base( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test k10 plus return to base.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + } + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.DOCK, "command", "default" + ) + + +async def test_k10_plus_pause( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test k10 plus pause.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + } + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, SERVICE_PAUSE, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.STOP, "command", "default" + ) + + +async def test_k10_plus_set_start( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K10 plus start.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.START, "command", "default" + ) + + +async def test_k20_plus_pro_set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K10 plus set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV2Commands.CHANGE_PARAM, + "command", + { + "fanLevel": 1, + "waterLevel": 1, + "times": 1, + }, + ) + + +async def test_k20_plus_pro_return_to_base( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K20+ Pro return to base.""" + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCleanerV2Commands.DOCK, "command", "default" + ) + + +async def test_k20_plus_pro_pause( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K20+ Pro pause.""" + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, SERVICE_PAUSE, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCleanerV2Commands.PAUSE, "command", "default" + ) + + +async def test_k20_plus_pro_start( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K20+ Pro start.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV2Commands.START_CLEAN, + "command", + { + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": 1, + "times": 1, + }, + }, + ) + + +async def test_k10_plus_pro_combo_set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test k10+ Pro Combo set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="Robot Vacuum Cleaner K10+ Pro Combo", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "Robot Vacuum Cleaner K10+ Pro Combo", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV2Commands.CHANGE_PARAM, + "command", + { + "fanLevel": 1, + "times": 1, + }, + ) + + +async def test_s20_start( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test s20 start.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="S20", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "s20", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV3Commands.START_CLEAN, + "command", + { + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": 0, + "waterLevel": 1, + "times": 1, + }, + }, + ) + + +async def test_s20set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test s20 set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="S20", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "S20", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV3Commands.CHANGE_PARAM, + "command", + { + "fanLevel": 1, + "waterLevel": 1, + "times": 1, + }, + ) diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0a887bbcae3..513b01ef278 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -32,7 +32,6 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader, MockStreamReaderChunked @@ -161,8 +160,7 @@ async def setup_dsm_with_filestation( hass: HomeAssistant, mock_dsm_with_filestation: MagicMock, ): - """Mock setup of synology dsm config entry and backup integration.""" - async_initialize_backup(hass) + """Mock setup of synology dsm config entry.""" with ( patch( "homeassistant.components.synology_dsm.common.SynologyDSM", @@ -220,7 +218,6 @@ async def test_agents_not_loaded( ) -> None: """Test backup agent with no loaded config entry.""" with patch("homeassistant.components.backup.is_hassio", return_value=False): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/system_bridge/snapshots/test_media_source.ambr b/tests/components/system_bridge/snapshots/test_media_source.ambr index 954332c932a..695a35f17d9 100644 --- a/tests/components/system_bridge/snapshots/test_media_source.ambr +++ b/tests/components/system_bridge/snapshots/test_media_source.ambr @@ -28,12 +28,14 @@ # name: test_file[system_bridge_media_source_file_image] dict({ 'mime_type': 'image/jpeg', + 'path': None, 'url': 'http://127.0.0.1:9170/api/media/file/data?token=abc-123-def-456-ghi&base=documents&path=testimage.jpg', }) # --- # name: test_file[system_bridge_media_source_file_text] dict({ 'mime_type': 'text/plain', + 'path': None, 'url': 'http://127.0.0.1:9170/api/media/file/data?token=abc-123-def-456-ghi&base=documents&path=testfile.txt', }) # --- diff --git a/tests/components/tado/snapshots/test_binary_sensor.ambr b/tests/components/tado/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5920e6bbf11 --- /dev/null +++ b/tests/components/tado/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1230 @@ +# serializer version: 1 +# name: test_entities[binary_sensor.air_conditioning_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Air Conditioning Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_overlay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning Overlay', + 'termination': 'TADO_MODE', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning Power', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Air Conditioning Window', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Air Conditioning with fanlevel Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_overlay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with fanlevel Overlay', + 'termination': 'MANUAL', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with fanlevel Power', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Air Conditioning with fanlevel Window', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_swing_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Air Conditioning with swing Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_overlay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_swing_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with swing Overlay', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_swing_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with swing Power', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.air_conditioning_with_swing_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Air Conditioning with swing Window', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.baseboard_heater_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Baseboard Heater Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_early_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.baseboard_heater_early_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Early start', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'early_start', + 'unique_id': 'early start 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_early_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Baseboard Heater Early start', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_early_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_overlay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.baseboard_heater_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Baseboard Heater Overlay', + 'termination': 'MANUAL', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.baseboard_heater_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Baseboard Heater Power', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.baseboard_heater_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Baseboard Heater Window', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.second_water_heater_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Second Water Heater Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.second_water_heater_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_overlay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.second_water_heater_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Second Water Heater Overlay', + 'termination': 'TADO_MODE', + }), + 'context': , + 'entity_id': 'binary_sensor.second_water_heater_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.second_water_heater_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Second Water Heater Power', + }), + 'context': , + 'entity_id': 'binary_sensor.second_water_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.water_heater_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_heater_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.water_heater_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Water Heater Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.water_heater_overlay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_heater_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.water_heater_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Water Heater Overlay', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.water_heater_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_heater_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.water_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Water Heater Power', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.wr1_connection_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.wr1_connection_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection state', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_state', + 'unique_id': 'connection state WR1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.wr1_connection_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'WR1 Connection state', + }), + 'context': , + 'entity_id': 'binary_sensor.wr1_connection_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.wr4_connection_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.wr4_connection_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection state', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_state', + 'unique_id': 'connection state WR4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.wr4_connection_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'WR4 Connection state', + }), + 'context': , + 'entity_id': 'binary_sensor.wr4_connection_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tado/snapshots/test_climate.ambr b/tests/components/tado/snapshots/test_climate.ambr index 6ba35b6f6f2..fb1dd6d46d1 100644 --- a/tests/components/tado/snapshots/test_climate.ambr +++ b/tests/components/tado/snapshots/test_climate.ambr @@ -93,6 +93,429 @@ }), ) # --- +# name: test_entities[climate.air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'AIR_CONDITIONING 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 60.9, + 'current_temperature': 24.8, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'friendly_name': 'Air Conditioning', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'offset_celsius': -1.0, + 'offset_fahrenheit': -1.8, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 17.8, + }), + 'context': , + 'entity_id': 'climate.air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_entities[climate.air_conditioning_with_fanlevel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'high', + 'medium', + 'auto', + 'low', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'swing_modes': list([ + 'vertical', + 'horizontal', + 'both', + 'off', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioning_with_fanlevel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'AIR_CONDITIONING 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.air_conditioning_with_fanlevel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 70.9, + 'current_temperature': 24.3, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'fan_mode': 'high', + 'fan_modes': list([ + 'high', + 'medium', + 'auto', + 'low', + ]), + 'friendly_name': 'Air Conditioning with fanlevel', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'swing_mode': 'both', + 'swing_modes': list([ + 'vertical', + 'horizontal', + 'both', + 'off', + ]), + 'target_temp_step': 1.0, + 'temperature': 25.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioning_with_fanlevel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_entities[climate.air_conditioning_with_swing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'swing_modes': list([ + 'on', + 'off', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioning_with_swing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'AIR_CONDITIONING 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.air_conditioning_with_swing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 42.3, + 'current_temperature': 20.9, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'friendly_name': 'Air Conditioning with swing', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'offset_celsius': -1.0, + 'offset_fahrenheit': -1.8, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'on', + 'off', + ]), + 'target_temp_step': 1.0, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioning_with_swing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entities[climate.baseboard_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.baseboard_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'HEATING 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.baseboard_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 45.2, + 'current_temperature': 20.6, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'friendly_name': 'Baseboard Heater', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'offset_celsius': -1.0, + 'offset_fahrenheit': -1.8, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 20.5, + }), + 'context': , + 'entity_id': 'climate.baseboard_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_heater_set_temperature _Call( tuple( diff --git a/tests/components/tado/snapshots/test_sensor.ambr b/tests/components/tado/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2040bd737c8 --- /dev/null +++ b/tests/components/tado/snapshots/test_sensor.ambr @@ -0,0 +1,1240 @@ +# serializer version: 1 +# name: test_entities[sensor.air_conditioning_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac', + 'unique_id': 'ac 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning AC', + 'time': '2020-03-05T04:01:07.162Z', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ON', + }) +# --- +# name: test_entities[sensor.air_conditioning_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 3 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.air_conditioning_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Air Conditioning Humidity', + 'state_class': , + 'time': '2020-03-05T03:57:38.850Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.9', + }) +# --- +# name: test_entities[sensor.air_conditioning_tado_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_tado_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning Tado mode', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.air_conditioning_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 3 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.air_conditioning_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioning Temperature', + 'setting': 0, + 'state_class': , + 'time': '2020-03-05T03:57:38.850Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.76', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_fanlevel_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac', + 'unique_id': 'ac 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with fanlevel AC', + 'time': '2022-07-13T18: 06: 58.183Z', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ON', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_fanlevel_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 6 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Air Conditioning with fanlevel Humidity', + 'state_class': , + 'time': '2024-06-28T22: 23: 15.679Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.9', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_tado_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_fanlevel_tado_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with fanlevel Tado mode', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_fanlevel_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 6 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioning with fanlevel Temperature', + 'setting': 0, + 'state_class': , + 'time': '2024-06-28T22: 23: 15.679Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.3', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_swing_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac', + 'unique_id': 'ac 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with swing AC', + 'time': '2020-03-27T23:02:22.260Z', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ON', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_swing_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 5 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Air Conditioning with swing Humidity', + 'state_class': , + 'time': '2020-03-28T02:09:27.830Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.3', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_tado_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_swing_tado_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with swing Tado mode', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioning_with_swing_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 5 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioning with swing Temperature', + 'setting': 0, + 'state_class': , + 'time': '2020-03-28T02:09:27.830Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.88', + }) +# --- +# name: test_entities[sensor.baseboard_heater_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baseboard_heater_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating', + 'unique_id': 'heating 1 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.baseboard_heater_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baseboard Heater Heating', + 'state_class': , + 'time': '2020-03-10T07:47:45.978Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.baseboard_heater_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baseboard_heater_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 1 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.baseboard_heater_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Baseboard Heater Humidity', + 'state_class': , + 'time': '2020-03-10T07:44:11.947Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.2', + }) +# --- +# name: test_entities[sensor.baseboard_heater_tado_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baseboard_heater_tado_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.baseboard_heater_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baseboard Heater Tado mode', + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.baseboard_heater_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baseboard_heater_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 1 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.baseboard_heater_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Baseboard Heater Temperature', + 'setting': 0, + 'state_class': , + 'time': '2020-03-10T07:44:11.947Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.65', + }) +# --- +# name: test_entities[sensor.home_name_automatic_geofencing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_name_automatic_geofencing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic geofencing', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_geofencing', + 'unique_id': 'automatic geofencing 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_automatic_geofencing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Automatic geofencing', + }), + 'context': , + 'entity_id': 'sensor.home_name_automatic_geofencing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'True', + }) +# --- +# name: test_entities[sensor.home_name_geofencing_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_name_geofencing_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Geofencing mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'geofencing_mode', + 'unique_id': 'geofencing mode 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_geofencing_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Geofencing mode', + }), + 'context': , + 'entity_id': 'sensor.home_name_geofencing_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Home (Auto)', + }) +# --- +# name: test_entities[sensor.home_name_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_name_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': 'outdoor temperature 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.home_name_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'home name Outdoor temperature', + 'state_class': , + 'time': '2020-12-22T08:13:13.652Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_name_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.46', + }) +# --- +# name: test_entities[sensor.home_name_solar_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_name_solar_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Solar percentage', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_percentage', + 'unique_id': 'solar percentage 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.home_name_solar_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Solar percentage', + 'state_class': , + 'time': '2020-12-22T08:13:13.652Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_name_solar_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.1', + }) +# --- +# name: test_entities[sensor.home_name_tado_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_name_tado_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Tado mode', + }), + 'context': , + 'entity_id': 'sensor.home_name_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.home_name_weather_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_name_weather_condition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather condition', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'weather_condition', + 'unique_id': 'weather condition 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_weather_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Weather condition', + 'time': '2020-12-22T08:13:13.652Z', + }), + 'context': , + 'entity_id': 'sensor.home_name_weather_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fog', + }) +# --- +# name: test_entities[sensor.second_water_heater_tado_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.second_water_heater_tado_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.second_water_heater_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Second Water Heater Tado mode', + }), + 'context': , + 'entity_id': 'sensor.second_water_heater_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.water_heater_tado_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_heater_tado_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.water_heater_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Tado mode', + }), + 'context': , + 'entity_id': 'sensor.water_heater_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- diff --git a/tests/components/tado/snapshots/test_switch.ambr b/tests/components/tado/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c2f00649f1d --- /dev/null +++ b/tests/components/tado/snapshots/test_switch.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_entities[switch.baseboard_heater_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.baseboard_heater_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '1 1 child-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.baseboard_heater_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baseboard Heater Child lock', + }), + 'context': , + 'entity_id': 'switch.baseboard_heater_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tado/snapshots/test_water_heater.ambr b/tests/components/tado/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..5e10af60c8d --- /dev/null +++ b/tests/components/tado/snapshots/test_water_heater.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_entities[water_heater.second_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.second_water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[water_heater.second_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Second Water Heater', + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + 'operation_mode': 'heat', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 30.0, + }), + 'context': , + 'entity_id': 'water_heater.second_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_entities[water_heater.water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[water_heater.water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Water Heater', + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + 'operation_mode': 'auto', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 65.0, + }), + 'context': , + 'entity_id': 'water_heater.water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/tado/test_binary_sensor.py b/tests/components/tado/test_binary_sensor.py index 78cd91c56c6..9a0b94883fa 100644 --- a/tests/components/tado/test_binary_sensor.py +++ b/tests/components/tado/test_binary_sensor.py @@ -1,69 +1,35 @@ -"""The sensor tests for the tado platform.""" +"""The binary sensor tests for the tado platform.""" -from homeassistant.const import STATE_OFF, STATE_ON +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform -async def test_air_con_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of aircon sensors.""" + +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test creation of binary sensor.""" await async_init_integration(hass) - state = hass.states.get("binary_sensor.air_conditioning_power") - assert state.state == STATE_ON + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - state = hass.states.get("binary_sensor.air_conditioning_connectivity") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.air_conditioning_overlay") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.air_conditioning_window") - assert state.state == STATE_OFF - - -async def test_heater_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.baseboard_heater_power") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.baseboard_heater_connectivity") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.baseboard_heater_early_start") - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.baseboard_heater_overlay") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.baseboard_heater_window") - assert state.state == STATE_OFF - - -async def test_water_heater_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of water heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.water_heater_connectivity") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.water_heater_overlay") - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.water_heater_power") - assert state.state == STATE_ON - - -async def test_home_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of home binary sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.wr1_connection_state") - assert state.state == STATE_ON + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 0699551c9c0..71ee0471e5f 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -1,5 +1,6 @@ -"""The sensor tests for the tado platform.""" +"""The climate tests for the tado platform.""" +from collections.abc import AsyncGenerator from unittest.mock import patch from PyTado.interface.api.my_tado import TadoZone @@ -13,128 +14,33 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.components.tado import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration - -async def test_air_con(hass: HomeAssistant) -> None: - """Test creation of aircon climate.""" - - await async_init_integration(hass) - - state = hass.states.get("climate.air_conditioning") - assert state.state == "cool" - - expected_attributes = { - "current_humidity": 60.9, - "current_temperature": 24.8, - "fan_mode": "auto", - "fan_modes": ["auto", "high", "medium", "low"], - "friendly_name": "Air Conditioning", - "hvac_action": "cooling", - "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], - "max_temp": 31.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "supported_features": 409, - "target_temp_step": 1, - "temperature": 17.8, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) +from tests.common import MockConfigEntry, snapshot_platform -async def test_heater(hass: HomeAssistant) -> None: - """Test creation of heater climate.""" - - await async_init_integration(hass) - - state = hass.states.get("climate.baseboard_heater") - assert state.state == "heat" - - expected_attributes = { - "current_humidity": 45.2, - "current_temperature": 20.6, - "friendly_name": "Baseboard Heater", - "hvac_action": "idle", - "hvac_modes": ["off", "auto", "heat"], - "max_temp": 31.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "supported_features": 401, - "target_temp_step": 1, - "temperature": 20.5, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.CLIMATE]): + yield -async def test_smartac_with_swing(hass: HomeAssistant) -> None: - """Test creation of smart ac with swing climate.""" - - await async_init_integration(hass) - - state = hass.states.get("climate.air_conditioning_with_swing") - assert state.state == "auto" - - expected_attributes = { - "current_humidity": 42.3, - "current_temperature": 20.9, - "fan_mode": "auto", - "fan_modes": ["auto", "high", "medium", "low"], - "friendly_name": "Air Conditioning with swing", - "hvac_action": "heating", - "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], - "max_temp": 30.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "swing_modes": ["on", "off"], - "supported_features": 441, - "target_temp_step": 1.0, - "temperature": 20.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) - - -async def test_smartac_with_fanlevel_vertical_and_horizontal_swing( - hass: HomeAssistant, +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: - """Test creation of smart ac with swing climate.""" + """Test creation of climate entities.""" await async_init_integration(hass) - state = hass.states.get("climate.air_conditioning_with_fanlevel") - assert state.state == "heat" + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - expected_attributes = { - "current_humidity": 70.9, - "current_temperature": 24.3, - "fan_mode": "high", - "fan_modes": ["high", "medium", "auto", "low"], - "friendly_name": "Air Conditioning with fanlevel", - "hvac_action": "heating", - "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], - "max_temp": 31.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "swing_modes": ["vertical", "horizontal", "both", "off"], - "supported_features": 441, - "target_temp_step": 1.0, - "temperature": 25.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_heater_set_temperature( diff --git a/tests/components/tado/test_sensor.py b/tests/components/tado/test_sensor.py index 0fa7a9ca370..8445683d11d 100644 --- a/tests/components/tado/test_sensor.py +++ b/tests/components/tado/test_sensor.py @@ -1,62 +1,35 @@ """The sensor tests for the tado platform.""" +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform -async def test_air_con_create_sensors(hass: HomeAssistant) -> None: - """Test creation of aircon sensors.""" + +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test creation of sensor entities.""" await async_init_integration(hass) - state = hass.states.get("sensor.air_conditioning_tado_mode") - assert state.state == "HOME" + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - state = hass.states.get("sensor.air_conditioning_temperature") - assert state.state == "24.76" - - state = hass.states.get("sensor.air_conditioning_ac") - assert state.state == "ON" - - state = hass.states.get("sensor.air_conditioning_humidity") - assert state.state == "60.9" - - -async def test_home_create_sensors(hass: HomeAssistant) -> None: - """Test creation of home sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("sensor.home_name_outdoor_temperature") - assert state.state == "7.46" - - state = hass.states.get("sensor.home_name_solar_percentage") - assert state.state == "2.1" - - state = hass.states.get("sensor.home_name_weather_condition") - assert state.state == "fog" - - -async def test_heater_create_sensors(hass: HomeAssistant) -> None: - """Test creation of heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("sensor.baseboard_heater_tado_mode") - assert state.state == "HOME" - - state = hass.states.get("sensor.baseboard_heater_temperature") - assert state.state == "20.65" - - state = hass.states.get("sensor.baseboard_heater_humidity") - assert state.state == "45.2" - - -async def test_water_heater_create_sensors(hass: HomeAssistant) -> None: - """Test creation of water heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("sensor.water_heater_tado_mode") - assert state.state == "HOME" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/tado/test_switch.py b/tests/components/tado/test_switch.py index 2112f3a1ac7..6bfdf1283d1 100644 --- a/tests/components/tado/test_switch.py +++ b/tests/components/tado/test_switch.py @@ -1,28 +1,45 @@ -"""The sensor tests for the tado platform.""" +"""The switch tests for the tado platform.""" +from collections.abc import AsyncGenerator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.components.tado import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform + CHILD_LOCK_SWITCH_ENTITY = "switch.baseboard_heater_child_lock" -async def test_child_lock(hass: HomeAssistant) -> None: - """Test creation of child lock entity.""" +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.SWITCH]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test creation of switch entities.""" await async_init_integration(hass) - state = hass.states.get(CHILD_LOCK_SWITCH_ENTITY) - assert state.state == STATE_OFF + + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/tado/test_water_heater.py b/tests/components/tado/test_water_heater.py index 223a1fda16a..7c13ba1604e 100644 --- a/tests/components/tado/test_water_heater.py +++ b/tests/components/tado/test_water_heater.py @@ -1,49 +1,35 @@ -"""The sensor tests for the tado platform.""" +"""The water heater tests for the tado platform.""" +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform -async def test_water_heater_create_sensors(hass: HomeAssistant) -> None: + +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.WATER_HEATER]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test creation of water heater.""" await async_init_integration(hass) - state = hass.states.get("water_heater.water_heater") - assert state.state == "auto" + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - expected_attributes = { - "current_temperature": None, - "friendly_name": "Water Heater", - "max_temp": 31.0, - "min_temp": 16.0, - "operation_list": ["auto", "heat", "off"], - "operation_mode": "auto", - "supported_features": 3, - "target_temp_high": None, - "target_temp_low": None, - "temperature": 65.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) - - state = hass.states.get("water_heater.second_water_heater") - assert state.state == "heat" - - expected_attributes = { - "current_temperature": None, - "friendly_name": "Second Water Heater", - "max_temp": 31.0, - "min_temp": 16.0, - "operation_list": ["auto", "heat", "off"], - "operation_mode": "heat", - "supported_features": 3, - "target_temp_high": None, - "target_temp_low": None, - "temperature": 30.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 5d166018160..2cf93435bbf 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -66,7 +66,6 @@ '_3c_e9_e_6d_21_84_-door1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -76,7 +75,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) @@ -148,7 +146,6 @@ '_3c_e9_e_6d_21_84_-door2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -158,7 +155,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index 0e4bb4e4e41..12b99997db0 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -70,7 +70,6 @@ '_3c_e9_e_6d_21_84_', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -80,7 +79,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': None, }) diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index a1a98b028e3..a14dcfc44f1 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -67,7 +67,6 @@ '_3c_e9_e_6d_21_84_-door1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -77,7 +76,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) @@ -150,7 +148,6 @@ '_3c_e9_e_6d_21_84_-door2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -160,7 +157,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index ffa2c5df7fd..f9132530cee 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -79,7 +79,6 @@ '_3c_e9_e_6d_21_84_', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -89,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': None, }) diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index 28b5ef7a7ed..38874d08f3a 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '0000-0000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tedee', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0000-0000', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -50,7 +48,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tedee', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index a568a7dcd82..a73e5c746aa 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -66,7 +66,6 @@ '98765', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tedee', @@ -76,7 +75,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 66c3c43ea86..489cb034ac2 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -96,7 +96,7 @@ def mock_external_calls() -> Generator[None]: max_reaction_count=100, accent_color_id=AccentColor.COLOR_000, ) - test_user = User(123456, "Testbot", True) + test_user = User(123456, "Testbot", True, "mock last name", "mock username") message = Message( message_id=12345, date=datetime.now(), diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 0287ccc5dfa..0886246b7e1 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.telegram_bot.const import ( PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigSubentry @@ -62,7 +63,7 @@ async def test_options_flow( await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][ATTR_PARSER] is None + assert result["data"][ATTR_PARSER] == PARSER_PLAIN_TEXT async def test_reconfigure_flow_broadcast( @@ -89,7 +90,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, }, ) await hass.async_block_till_done() @@ -104,7 +107,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -112,17 +117,18 @@ async def test_reconfigure_flow_broadcast( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert mock_webhooks_config_entry.data[CONF_PLATFORM] == PLATFORM_BROADCAST + assert mock_webhooks_config_entry.data[CONF_PROXY_URL] == "https://test" async def test_reconfigure_flow_webhooks( hass: HomeAssistant, - mock_webhooks_config_entry: MockConfigEntry, + mock_broadcast_config_entry: MockConfigEntry, mock_external_calls: None, ) -> None: """Test reconfigure flow for webhook.""" - mock_webhooks_config_entry.add_to_hass(hass) + mock_broadcast_config_entry.add_to_hass(hass) - result = await mock_webhooks_config_entry.start_reconfigure_flow(hass) + result = await mock_broadcast_config_entry.start_reconfigure_flow(hass) assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -131,7 +137,9 @@ async def test_reconfigure_flow_webhooks( result["flow_id"], { CONF_PLATFORM: PLATFORM_WEBHOOKS, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -191,15 +199,13 @@ async def test_reconfigure_flow_webhooks( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_webhooks_config_entry.data[CONF_URL] == "https://reconfigure" - assert mock_webhooks_config_entry.data[CONF_TRUSTED_NETWORKS] == [ + assert mock_broadcast_config_entry.data[CONF_URL] == "https://reconfigure" + assert mock_broadcast_config_entry.data[CONF_TRUSTED_NETWORKS] == [ "149.154.160.0/20" ] -async def test_create_entry( - hass: HomeAssistant, -) -> None: +async def test_create_entry(hass: HomeAssistant) -> None: """Test user flow.""" # test: no input @@ -215,24 +221,46 @@ async def test_create_entry( # test: invalid proxy url + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PLATFORM: PLATFORM_WEBHOOKS, + CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, + }, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_proxy_url" + assert result["description_placeholders"]["error_field"] == "proxy url" + + # test: telegram error + with patch( "homeassistant.components.telegram_bot.config_flow.Bot.get_me", ) as mock_bot: - mock_bot.side_effect = NetworkError("mock invalid proxy") + mock_bot.side_effect = NetworkError("mock network error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://proxy", + }, }, ) await hass.async_block_till_done() assert result["step_id"] == "user" assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == "invalid_proxy_url" + assert result["errors"]["base"] == "telegram_error" + assert result["description_placeholders"]["error_message"] == "mock network error" # test: valid input, to continue with webhooks step @@ -245,7 +273,9 @@ async def test_create_entry( { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "https://proxy", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://proxy", + }, }, ) await hass.async_block_till_done() @@ -373,7 +403,7 @@ async def test_subentry_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert subentry.subentry_type == SUBENTRY_TYPE_ALLOWED_CHAT_IDS - assert subentry.title == "mock title" + assert subentry.title == "mock title (987654321)" assert subentry.unique_id == "987654321" assert subentry.data == {CONF_CHAT_ID: 987654321} @@ -490,9 +520,22 @@ async def test_import_multiple( CONF_BOT_COUNT: 2, } - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_chat", + return_value=ChatFullInfo( + id=987654321, + title="mock title", + first_name="mock first_name", + type="PRIVATE", + max_reaction_count=100, + accent_color_id=AccentColor.COLOR_000, + ), + ), ): # test: import first entry success @@ -535,6 +578,7 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch( diff --git a/tests/components/telegram_bot/test_notify.py b/tests/components/telegram_bot/test_notify.py new file mode 100644 index 00000000000..d43d5492760 --- /dev/null +++ b/tests/components/telegram_bot/test_notify.py @@ -0,0 +1,72 @@ +"""Test the telegram bot notify platform.""" + +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from freezegun.api import freeze_time +from telegram import Chat, Message +from telegram.constants import ChatType, ParseMode + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Context, HomeAssistant + +from tests.common import async_capture_events + + +@freeze_time("2025-01-09T12:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + webhook_platform: None, +) -> None: + """Test publishing ntfy message.""" + + context = Context() + events = async_capture_events(hass, "telegram_sent") + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.send_message", + AsyncMock( + return_value=Message( + message_id=12345, + date=datetime.now(), + chat=Chat(id=123456, type=ChatType.PRIVATE), + ) + ), + ) as mock_send_message: + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.telegram_bot_123456_12345678", + ATTR_MESSAGE: "mock message", + ATTR_TITLE: "mock title", + }, + blocking=True, + context=context, + ) + await hass.async_block_till_done() + + mock_send_message.assert_called_once_with( + 12345678, + "mock title\nmock message", + parse_mode=ParseMode.MARKDOWN, + disable_web_page_preview=None, + disable_notification=False, + reply_to_message_id=None, + reply_markup=None, + read_timeout=None, + message_thread_id=None, + ) + + state = hass.states.get("notify.telegram_bot_123456_12345678") + assert state + assert state.state == "2025-01-09T12:00:00+00:00" + + assert len(events) == 1 + assert events[0].context == context diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index fd313867561..eec2bd5ecf7 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -50,7 +50,9 @@ from homeassistant.components.telegram_bot.const import ( ATTR_VERIFY_SSL, CONF_CONFIG_ENTRY_ID, DOMAIN, + PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, + SECTION_ADVANCED_SETTINGS, SERVICE_ANSWER_CALLBACK_QUERY, SERVICE_DELETE_MESSAGE, SERVICE_EDIT_CAPTION, @@ -172,6 +174,15 @@ async def test_send_message( assert len(events) == 1 assert events[0].context == context + config_entry = hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "1234567890:ABC" + ) + assert events[0].data["bot"]["config_entry_id"] == config_entry.entry_id + assert events[0].data["bot"]["id"] == 123456 + assert events[0].data["bot"]["first_name"] == "Testbot" + assert events[0].data["bot"]["last_name"] == "mock last name" + assert events[0].data["bot"]["username"] == "mock username" + assert len(response["chats"]) == 1 assert (response["chats"][0]["message_id"]) == 12345 @@ -182,6 +193,7 @@ async def test_send_message( ( { ATTR_MESSAGE: "test_message", + ATTR_PARSER: PARSER_PLAIN_TEXT, ATTR_KEYBOARD_INLINE: "command1:/cmd1,/cmd2,mock_link:https://mock_link", }, InlineKeyboardMarkup( @@ -198,6 +210,7 @@ async def test_send_message( ( { ATTR_MESSAGE: "test_message", + ATTR_PARSER: PARSER_PLAIN_TEXT, ATTR_KEYBOARD_INLINE: [ [["command1", "/cmd1"]], [["mock_link", "https://mock_link"]], @@ -249,7 +262,7 @@ async def test_send_message_with_inline_keyboard( mock_send_message.assert_called_once_with( 12345678, "test_message", - parse_mode=ParseMode.MARKDOWN, + parse_mode=None, disable_web_page_preview=None, disable_notification=False, reply_to_message_id=None, @@ -360,7 +373,7 @@ async def test_webhook_endpoint_generates_telegram_text_event( events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -387,7 +400,7 @@ async def test_webhook_endpoint_generates_telegram_command_event( events = async_capture_events(hass, "telegram_command") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_command, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -414,7 +427,7 @@ async def test_webhook_endpoint_generates_telegram_callback_event( events = async_capture_events(hass, "telegram_callback") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_callback_query, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -475,6 +488,16 @@ async def test_polling_platform_message_text_update( assert len(events) == 1 assert events[0].data["text"] == update_message_text["message"]["text"] + + config_entry = hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "1234567890:ABC" + ) + assert events[0].data["bot"]["config_entry_id"] == config_entry.entry_id + assert events[0].data["bot"]["id"] == 123456 + assert events[0].data["bot"]["first_name"] == "Testbot" + assert events[0].data["bot"]["last_name"] == "mock last name" + assert events[0].data["bot"]["username"] == "mock username" + assert isinstance(events[0].context, Context) @@ -590,7 +613,7 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=unauthorized_update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -614,7 +637,7 @@ async def test_webhook_endpoint_without_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, ) assert response.status == 401 @@ -632,7 +655,7 @@ async def test_webhook_endpoint_invalid_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": incorrect_secret_token}, ) @@ -677,13 +700,35 @@ async def test_send_message_with_config_entry( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: send message to invalid chat id + + with pytest.raises(HomeAssistantError) as err: + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + { + CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, + ATTR_MESSAGE: "mock message", + ATTR_TARGET: [123456, 1], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "failed_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: send message to valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, { CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, ATTR_MESSAGE: "mock message", - ATTR_TARGET: 1, + ATTR_TARGET: 123456, }, blocking=True, return_response=True, @@ -700,6 +745,7 @@ async def test_send_message_no_chat_id_error( data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch("homeassistant.components.telegram_bot.config_flow.Bot.get_me"): @@ -725,7 +771,7 @@ async def test_send_message_no_chat_id_error( ) assert err.value.translation_key == "missing_allowed_chat_ids" - assert err.value.translation_placeholders["bot_name"] == "Testbot" + assert err.value.translation_placeholders["bot_name"] == "Testbot mock last name" async def test_send_message_config_entry_error( @@ -767,6 +813,23 @@ async def test_delete_message( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: delete message with invalid chat id + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_MESSAGE, + {ATTR_CHAT_ID: 1, ATTR_MESSAGEID: "last"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "invalid_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: delete message with valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, @@ -808,7 +871,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_MESSAGE, - {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -822,7 +885,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_CAPTION, - {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -836,7 +899,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_REPLYMARKUP, - {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) diff --git a/tests/components/telegram_bot/test_webhooks.py b/tests/components/telegram_bot/test_webhooks.py index 3419d33074d..a02bb3e3358 100644 --- a/tests/components/telegram_bot/test_webhooks.py +++ b/tests/components/telegram_bot/test_webhooks.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from telegram import WebhookInfo from telegram.error import TimedOut +from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -115,7 +116,7 @@ async def test_webhooks_update_invalid_json( client = await hass_client() response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) assert response.status == 400 @@ -139,7 +140,7 @@ async def test_webhooks_unauthorized_network( return_value=IPv4Network("1.2.3.4"), ) as mock_remote: response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", json="mock json", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index c69c9e9e9a4..c57d1dcbfab 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -4,11 +4,15 @@ from enum import Enum import pytest +from homeassistant.components import template +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.conftest import WebSocketGenerator class ConfigurationStyle(Enum): @@ -19,6 +23,88 @@ class ConfigurationStyle(Enum): TRIGGER = "Trigger" +def make_test_trigger(*entities: str) -> dict: + """Make a test state trigger.""" + return { + "trigger": [ + { + "trigger": "state", + "entity_id": list(entities), + }, + {"platform": "event", "event_type": "test_event"}, + ], + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], + } + + +async def async_setup_legacy_platforms( + hass: HomeAssistant, + domain: str, + slug: str, + count: int, + config: ConfigType, +) -> None: + """Do setup of any legacy platform that supports a keyed dictionary of template entities.""" + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + {domain: {"platform": "template", slug: config}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_state_format( + hass: HomeAssistant, + domain: str, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of template integration via modern format.""" + extra = extra_config or {} + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + {"template": {domain: config, **extra}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_trigger_format( + hass: HomeAssistant, + domain: str, + trigger: dict, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of template integration via trigger format.""" + extra = extra_config or {} + config = {"template": {domain: config, **trigger, **extra}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" @@ -51,3 +137,43 @@ async def caplog_setup_text(caplog: pytest.LogCaptureFixture) -> str: @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" + + +async def async_get_flow_preview_state( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + user_input: ConfigType, +) -> ConfigType: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": domain}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == domain + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + return msg["event"] diff --git a/tests/components/template/snapshots/test_cover.ambr b/tests/components/template/snapshots/test_cover.ambr new file mode 100644 index 00000000000..177dc8c883b --- /dev/null +++ b/tests/components/template/snapshots/test_cover.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/template/snapshots/test_fan.ambr b/tests/components/template/snapshots/test_fan.ambr new file mode 100644 index 00000000000..3026176ef97 --- /dev/null +++ b/tests/components/template/snapshots/test_fan.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/snapshots/test_light.ambr b/tests/components/template/snapshots/test_light.ambr new file mode 100644 index 00000000000..0740d56a72e --- /dev/null +++ b/tests/components/template/snapshots/test_light.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'My template', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/snapshots/test_lock.ambr b/tests/components/template/snapshots/test_lock.ambr new file mode 100644 index 00000000000..250fc6ba8d4 --- /dev/null +++ b/tests/components/template/snapshots/test_lock.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/template/snapshots/test_vacuum.ambr b/tests/components/template/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..01cc9c8ba82 --- /dev/null +++ b/tests/components/template/snapshots/test_vacuum.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index bdda5b44e94..215a10a4f40 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -46,6 +46,7 @@ 'last_ozone': None, 'last_pressure': None, 'last_temperature': '15.0', + 'last_uv_index': None, 'last_visibility': None, 'last_wind_bearing': None, 'last_wind_gust_speed': None, diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 1984b4ea2af..319d02a1056 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -23,9 +23,10 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache +from tests.conftest import WebSocketGenerator TEST_OBJECT_ID = "test_template_panel" TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" @@ -915,3 +916,92 @@ async def test_device_id( template_entity = entity_registry.async_get("alarm_control_panel.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + ALARM_DOMAIN, + {"name": "My template", "state": "{{ 'disarmed' }}"}, + ) + + assert state["state"] == AlarmControlPanelState.DISARMED + + +@pytest.mark.parametrize( + ("count", "panel_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('alarm_control_panel.test') }}", + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_panel") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.DISARMED) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + "alarm_arm_away", + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_AWAY + + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.ARMED_HOME) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_HOME + + +@pytest.mark.parametrize( + ("count", "panel_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('alarm_control_panel.test') }}", + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_panel") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + ALARM_DOMAIN, + "alarm_arm_away", + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 122801e6c59..b30051a52d2 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,9 +1,10 @@ """The tests for the Template Binary sensor platform.""" -from copy import deepcopy +from collections.abc import Generator from datetime import UTC, datetime, timedelta import logging -from unittest.mock import patch +from typing import Any +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -22,104 +23,234 @@ from homeassistant.const import ( from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.helpers.typing import ConfigType + +from .conftest import ( + ConfigurationStyle, + async_get_flow_preview_state, + async_setup_legacy_platforms, + async_setup_modern_state_format, + async_setup_modern_trigger_format, + make_test_trigger, +) from tests.common import ( MockConfigEntry, - assert_setup_component, async_fire_time_changed, + async_mock_restore_state_shutdown_restart, mock_restore_cache, mock_restore_cache_with_extra_data, ) +from tests.typing import WebSocketGenerator - -@pytest.mark.parametrize("count", [1]) -@pytest.mark.parametrize( - ("config", "domain", "entity_id", "name", "attributes"), - [ - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "value_template": "{{ True }}", - } - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - "test", - {"friendly_name": "test"}, - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ True }}", - } - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - "unnamed device", - {}, - ), - ], +_BEER_TRIGGER_VALUE_TEMPLATE = ( + "{% if trigger.event.data.beer < 0 %}" + "{{ 1 / 0 == 10 }}" + "{% elif trigger.event.data.beer == 0 %}" + "{{ None }}" + "{% else %}" + "{{ trigger.event.data.beer == 2 }}" + "{% endif %}" ) -@pytest.mark.usefixtures("start_ha") -async def test_setup_minimal(hass: HomeAssistant, entity_id, name, attributes) -> None: + + +TEST_OBJECT_ID = "test_binary_sensor" +TEST_ENTITY_ID = f"binary_sensor.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "binary_sensor.test_state" +TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" +TEST_STATE_TRIGGER = make_test_trigger( + TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID, TEST_ATTRIBUTE_ENTITY_ID +) +UNIQUE_ID_CONFIG = { + "unique_id": "not-so-unique-anymore", +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, config: ConfigType +) -> None: + """Do setup of binary sensor integration via legacy format.""" + await async_setup_legacy_platforms( + hass, binary_sensor.DOMAIN, "sensors", count, config + ) + + +async def async_setup_modern_format( + hass: HomeAssistant, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of binary sensor integration via modern format.""" + await async_setup_modern_state_format( + hass, binary_sensor.DOMAIN, count, config, extra_config + ) + + +async def async_setup_trigger_format( + hass: HomeAssistant, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of binary sensor integration via trigger format.""" + await async_setup_modern_trigger_format( + hass, binary_sensor.DOMAIN, TEST_STATE_TRIGGER, count, config, extra_config + ) + + +@pytest.fixture +async def setup_base_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: ConfigType | list[dict], + extra_template_options: ConfigType, +) -> None: + """Do setup of binary sensor integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, config, extra_template_options) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, config, extra_template_options) + + +async def async_setup_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: ConfigType, +) -> None: + """Do setup of binary sensor integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {TEST_OBJECT_ID: {"value_template": state_template, **extra_config}}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, + ) + + +@pytest.fixture +async def setup_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: dict[str, Any], +) -> None: + """Do setup of binary sensor integration.""" + await async_setup_binary_sensor(hass, count, style, state_template, extra_config) + + +@pytest.fixture +async def setup_single_attribute_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_value: str | dict, + state_template: str, + extra_config: dict, +) -> None: + """Do setup of binary sensor integration testing a single attribute.""" + extra = {attribute: attribute_value} if attribute and attribute_value else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "value_template": state_template, + **extra, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **extra, + **extra_config, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **extra, + **extra_config, + }, + ) + + +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), [(1, "{{ True }}", {})] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_setup_minimal(hass: HomeAssistant) -> None: """Test the setup.""" - state = hass.states.get(entity_id) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert state.name == name + assert state.name == TEST_OBJECT_ID assert state.state == STATE_ON - assert state.attributes == attributes + assert state.attributes == {"friendly_name": TEST_OBJECT_ID} -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "extra_config"), [ ( + 1, + "{{ True }}", { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ True }}", - "device_class": "motion", - } - }, - }, + "device_class": "motion", }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "{{ True }}", - "device_class": "motion", - } - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_setup(hass: HomeAssistant, entity_id) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_setup(hass: HomeAssistant) -> None: """Test the setup.""" - state = hass.states.get(entity_id) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert state.name == "virtual thingy" + assert state.name == TEST_OBJECT_ID assert state.state == STATE_ON assert state.attributes["device_class"] == "motion" @@ -232,173 +363,203 @@ async def test_setup_config_entry( ], ) @pytest.mark.usefixtures("start_ha") -async def test_setup_invalid_sensors(hass: HomeAssistant, count) -> None: +async def test_setup_invalid_sensors(hass: HomeAssistant, count: int) -> None: """Test setup with no sensors.""" assert len(hass.states.async_entity_ids("binary_sensor")) == count -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("state_template", "expected_result"), [ + ("{{ None }}", STATE_UNKNOWN), + ("{{ True }}", STATE_ON), + ("{{ False }}", STATE_OFF), + ("{{ 1 }}", STATE_ON), ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "icon_template": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "mdi:check" - "{% endif %}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "icon": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "mdi:check" - "{% endif %}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", + "{% if states('binary_sensor.three') in ('unknown','unavailable') %}" + "{{ None }}" + "{% else %}" + "{{ states('binary_sensor.three') == 'off' }}" + "{% endif %}", + STATE_UNKNOWN, ), + ("{{ 1 / 0 == 10 }}", STATE_UNAVAILABLE), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_icon_template(hass: HomeAssistant, entity_id) -> None: - """Test icon template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("icon") == "" +async def test_state( + hass: HomeAssistant, + state_template: str, + expected_result: str, +) -> None: + """Test the config flow.""" + hass.states.async_set("binary_sensor.one", "on") + hass.states.async_set("binary_sensor.two", "off") + hass.states.async_set("binary_sensor.three", "unknown") - hass.states.async_set("binary_sensor.test_state", STATE_ON) + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": state_template, + "template_type": binary_sensor.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get("binary_sensor.my_template") + assert state is not None + assert state.state == expected_result + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_value", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{% if is_state('binary_sensor.test_state', 'on') %}mdi:check{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), + [ + (ConfigurationStyle.LEGACY, "icon_template", ""), + (ConfigurationStyle.MODERN, "icon", ""), + (ConfigurationStyle.TRIGGER, "icon", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_icon_template(hass: HomeAssistant, initial_state: str | None) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") == initial_state + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "entity_picture_template": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "/local/sensor.png" - "{% endif %}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "picture": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "/local/sensor.png" - "{% endif %}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ 1 == 1 }}", + "{% if is_state('binary_sensor.test_state', 'on') %}/local/sensor.png{% endif %}", + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_entity_picture_template(hass: HomeAssistant, entity_id) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template", ""), + (ConfigurationStyle.MODERN, "picture", ""), + (ConfigurationStyle.TRIGGER, "picture", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_entity_picture_template( + hass: HomeAssistant, initial_state: str | None +) -> None: """Test entity_picture template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("entity_picture") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set("binary_sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/sensor.png" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "attributes": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ True }}", + {"test_attribute": "It {{ states.sensor.test_attribute.state }}."}, + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_attribute_templates(hass: HomeAssistant, entity_id) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_value"), + [ + (ConfigurationStyle.LEGACY, "attribute_templates", "It ."), + (ConfigurationStyle.MODERN, "attributes", "It ."), + (ConfigurationStyle.TRIGGER, "attributes", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_attribute_templates( + hass: HomeAssistant, initial_value: str | None +) -> None: """Test attribute_templates template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("test_attribute") == "It ." - hass.states.async_set("sensor.test_state", "Works2") + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("test_attribute") == initial_value + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works2") await hass.async_block_till_done() - hass.states.async_set("sensor.test_state", "Works") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works") await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It Works." +@pytest.mark.parametrize( + ("count", "state_template", "attribute_value", "extra_config"), + [ + ( + 1, + "{{ states.binary_sensor.test_sensor }}", + {"test_attribute": "{{ states.binary_sensor.unknown.attributes.picture }}"}, + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "attribute_templates"), + (ConfigurationStyle.MODERN, "attributes"), + (ConfigurationStyle.TRIGGER, "attributes"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_invalid_attribute_template( + hass: HomeAssistant, + style: ConfigurationStyle, + caplog_setup_text: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that errors are logged if rendering template fails.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + text = ( + "Template variable error: 'None' has no attribute 'attributes' when rendering" + ) + assert text in caplog_setup_text or text in caplog.text + + @pytest.fixture -async def setup_mock(): +def setup_mock() -> Generator[Mock]: """Do setup of sensor mock.""" with patch( "homeassistant.components.template.binary_sensor." - "BinarySensorTemplate._update_state" + "StateBinarySensorEntity._update_state" ) as _update_state: yield _update_state @@ -426,7 +587,7 @@ async def setup_mock(): ], ) @pytest.mark.usefixtures("start_ha") -async def test_match_all(hass: HomeAssistant, setup_mock) -> None: +async def test_match_all(hass: HomeAssistant, setup_mock: Mock) -> None: """Test template that is rerendered on any state lifecycle.""" init_calls = len(setup_mock.mock_calls) @@ -435,341 +596,264 @@ async def test_match_all(hass: HomeAssistant, setup_mock) -> None: assert len(setup_mock.mock_calls) == init_calls -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "extra_config"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - }, - }, - }, - }, + ( + 1, + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_event(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "initial_state"), + [ + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_binary_sensor_state(hass: HomeAssistant, initial_state: str) -> None: """Test the event.""" - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == initial_state - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON @pytest.mark.parametrize( - ("config", "count", "domain"), + ("count", "state_template", "extra_config", "attribute"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - }, 1, - binary_sensor.DOMAIN, - ), - ( - { - "template": [ - { - "binary_sensor": { - "name": "test on", - "state": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - }, - }, - { - "binary_sensor": { - "name": "test off", - "state": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - }, - }, - ] - }, - 2, - template.DOMAIN, - ), - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 10 / 2 }) }}', - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 10 / 2 }) }}', - }, - }, - }, - }, - 1, - binary_sensor.DOMAIN, - ), - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - }, - }, - }, - }, - 1, - binary_sensor.DOMAIN, - ), + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + "delay_on", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_template_delay_on_off(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "initial_state"), + [ + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize( + "attribute_value", + [ + 5, + "{{ dict(seconds=10 / 2) }}", + '{{ dict(seconds=states("sensor.test_attribute") | int(0)) }}', + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_delay_on( + hass: HomeAssistant, initial_state: str, freezer: FrozenDateTimeFactory +) -> None: """Test binary sensor template delay on.""" # Ensure the initial state is not on - assert hass.states.get("binary_sensor.test_on").state != STATE_ON - assert hass.states.get("binary_sensor.test_off").state != STATE_ON + assert hass.states.get(TEST_ENTITY_ID).state == initial_state - hass.states.async_set("input_number.delay", 5) - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) + assert hass.states.get(TEST_ENTITY_ID).state == initial_state + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_ON - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - # check with time changes - hass.states.async_set("sensor.test_state", STATE_OFF) + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - hass.states.async_set("sensor.test_state", STATE_ON) + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - hass.states.async_set("sensor.test_state", STATE_OFF) + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_OFF + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "extra_config", "attribute"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "true", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + 1, + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + "delay_off", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_available_without_availability_template( - hass: HomeAssistant, entity_id -) -> None: +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, + ], +) +@pytest.mark.parametrize( + "attribute_value", + [ + 5, + "{{ dict(seconds=10 / 2) }}", + '{{ dict(seconds=states("sensor.test_attribute") | int(0)) }}', + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Test binary sensor template delay off.""" + assert hass.states.get(TEST_ENTITY_ID).state != STATE_ON + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), + [ + ( + 1, + "{{ True }}", + { + "device_class": "motion", + "delay_off": 5, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_available_without_availability_template(hass: HomeAssistant) -> None: """Ensure availability is true without an availability_template.""" - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( + 1, + "{{ True }}", + "{{ is_state('binary_sensor.test_availability','on') }}", { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - "availability_template": "{{ is_state('sensor.test_state','on') }}", - }, - }, - }, + "device_class": "motion", + "delay_off": 5, }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "true", - "device_class": "motion", - "delay_off": 5, - "availability": "{{ is_state('sensor.test_state','on') }}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_template(hass: HomeAssistant, entity_id) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_availability_template(hass: HomeAssistant) -> None: """Test availability template.""" - hass.states.async_set("sensor.test_state", STATE_OFF) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_value", "extra_config"), + [(1, "{{ True }}", "{{ x - 12 }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "invalid_template": { - "value_template": "{{ states.binary_sensor.test_sensor }}", - "attribute_templates": { - "test_attribute": "{{ states.binary_sensor.unknown.attributes.picture }}" - }, - } - }, - }, - }, + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_invalid_attribute_template( - hass: HomeAssistant, caplog_setup_text -) -> None: - """Test that errors are logged if rendering template fails.""" - hass.states.async_set("binary_sensor.test_sensor", STATE_ON) - assert len(hass.states.async_all()) == 2 - assert ("test_attribute") in caplog_setup_text - assert ("TemplateError") in caplog_setup_text - - -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "my_sensor": { - "value_template": "{{ states.binary_sensor.test_sensor }}", - "availability_template": "{{ x - 12 }}", - }, - }, - }, - }, - ], -) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("binary_sensor.my_sensor").state != STATE_UNAVAILABLE - assert "UndefinedError: 'x' is undefined" in caplog_setup_text + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + text = "UndefinedError: 'x' is undefined" + assert text in caplog_setup_text or text in caplog.text -async def test_no_update_template_match_all( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_no_update_template_match_all(hass: HomeAssistant) -> None: """Test that we do not update sensors that match on all.""" hass.set_state(CoreState.not_running) @@ -835,172 +919,145 @@ async def test_no_update_template_match_all( assert hass.states.get("binary_sensor.all_attribute").state == STATE_OFF -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize(("count", "extra_template_options"), [(1, {})]) @pytest.mark.parametrize( - "config", + ("config", "style"), [ - { - "template": { - "unique_id": "group-id", - "binary_sensor": { - "name": "top-level", - "unique_id": "sensor-id", - "state": STATE_ON, + ( + { + "test_template_01": { + "value_template": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + "test_template_02": { + "value_template": "{{ True }}", + **UNIQUE_ID_CONFIG, }, }, - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_cover_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_cover_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_01", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, }, - }, - }, + { + "name": "test_template_02", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ( + [ + { + "name": "test_template_01", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_02", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_unique_id( +@pytest.mark.usefixtures("setup_base_binary_sensor") +async def test_unique_id(hass: HomeAssistant) -> None: + """Test unique_id option only creates one fan per id.""" + assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize( + ("count", "config", "extra_template_options"), + [ + ( + 1, + [ + { + "name": "test_a", + "state": "{{ True }}", + "unique_id": "a", + }, + { + "name": "test_b", + "state": "{{ True }}", + "unique_id": "b", + }, + ], + {"unique_id": "x"}, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_base_binary_sensor") +async def test_nested_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test unique_id option only creates one binary sensor per id.""" - assert len(hass.states.async_all()) == 2 + """Test a template unique_id propagates to switch unique_ids.""" + assert len(hass.states.async_all("binary_sensor")) == 2 - assert len(entity_registry.entities) == 2 - assert entity_registry.async_get_entity_id( - "binary_sensor", "template", "group-id-sensor-id" - ) - assert entity_registry.async_get_entity_id( - "binary_sensor", "template", "not-so-unique-anymore" - ) + entry = entity_registry.async_get("binary_sensor.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("binary_sensor.test_b") + assert entry + assert entry.unique_id == "x-b" -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_value", "extra_config"), + [(1, "{{ 1 == 1 }}", "{{ states.sensor.test_attribute.state }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "True", - "icon_template": "{{ states.sensor.test_state.state }}", - "device_class": "motion", - "delay_on": 5, - }, - }, - }, - }, + (ConfigurationStyle.LEGACY, "icon_template", ""), + (ConfigurationStyle.MODERN, "icon", ""), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_template_validation_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_template_icon_validation_error( + hass: HomeAssistant, initial_state: str, caplog: pytest.LogCaptureFixture ) -> None: """Test binary sensor template delay on.""" caplog.set_level(logging.ERROR) - state = hass.states.get("binary_sensor.test") - assert state.attributes.get("icon") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") == initial_state - hass.states.async_set("sensor.test_state", "mdi:check") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "mdi:check") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") - assert state.attributes.get("icon") == "mdi:check" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:check" - hass.states.async_set("sensor.test_state", "invalid_icon") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "invalid_icon") await hass.async_block_till_done() + assert len(caplog.records) == 1 assert caplog.records[0].message.startswith( "Error validating template result 'invalid_icon' from template" ) - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") is None -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), - [ - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "availability_template": "{{ is_state('sensor.bla', 'available') }}", - "entity_picture_template": "{{ 'blib' + 'blub' }}", - "icon_template": "mdi:{{ 1+2 }}", - "friendly_name": "{{ 'My custom ' + 'sensor' }}", - "value_template": "{{ true }}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "availability": "{{ is_state('sensor.bla', 'available') }}", - "picture": "{{ 'blib' + 'blub' }}", - "icon": "mdi:{{ 1+2 }}", - "name": "{{ 'My custom ' + 'sensor' }}", - "state": "{{ true }}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.my_custom_sensor", - ), - ], + ("count", "state_template"), [(1, "{{ states.binary_sensor.test_state.state }}")] ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_icon_picture(hass: HomeAssistant, entity_id) -> None: - """Test name, icon and picture templates are rendered at setup.""" - state = hass.states.get(entity_id) - assert state.state == "unavailable" - assert state.attributes == { - "entity_picture": "blibblub", - "friendly_name": "My custom sensor", - "icon": "mdi:3", - } - - hass.states.async_set("sensor.bla", "available") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state.state == "on" - assert state.attributes == { - "entity_picture": "blibblub", - "friendly_name": "My custom sensor", - "icon": "mdi:3", - } - - -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( - "config", - [ - { - "template": { - "binary_sensor": { - "name": "test", - "state": "{{ states.sensor.test_state.state == 'on' }}", - }, - }, - }, - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], ) @pytest.mark.parametrize( ("extra_config", "source_state", "restored_state", "initial_state"), @@ -1029,280 +1086,237 @@ async def test_availability_icon_picture(hass: HomeAssistant, entity_id) -> None ({"delay_on": 5}, STATE_ON, STATE_OFF, STATE_OFF), ({"delay_on": 5}, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN), ({"delay_on": 5}, STATE_ON, STATE_UNKNOWN, STATE_UNKNOWN), + ({}, None, STATE_ON, STATE_UNKNOWN), + ({}, None, STATE_OFF, STATE_UNKNOWN), + ({}, None, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({}, None, STATE_UNKNOWN, STATE_UNKNOWN), + ({"delay_off": 5}, None, STATE_ON, STATE_UNKNOWN), + ({"delay_off": 5}, None, STATE_OFF, STATE_UNKNOWN), + ({"delay_off": 5}, None, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({"delay_off": 5}, None, STATE_UNKNOWN, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_ON, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_OFF, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_UNKNOWN, STATE_UNKNOWN), ], ) async def test_restore_state( hass: HomeAssistant, - count, - domain, - config, - extra_config, - source_state, - restored_state, - initial_state, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: ConfigType, + source_state: str | None, + restored_state: str, + initial_state: str, ) -> None: """Test restoring template binary sensor.""" - hass.states.async_set("sensor.test_state", source_state) - fake_state = State( - "binary_sensor.test", - restored_state, - {}, - ) + hass.states.async_set(TEST_STATE_ENTITY_ID, source_state) + await hass.async_block_till_done() + + fake_state = State(TEST_ENTITY_ID, restored_state, {}) mock_restore_cache(hass, (fake_state,)) - config = deepcopy(config) - config["template"]["binary_sensor"].update(**extra_config) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) - await hass.async_block_till_done() + await async_setup_binary_sensor(hass, count, style, state_template, extra_config) - context = Context() - hass.bus.async_fire("test_event", {"beer": 2}, context=context) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == initial_state -@pytest.mark.parametrize(("count", "domain"), [(2, "template")]) @pytest.mark.parametrize( - "config", + ("count", "style", "state_template", "extra_config"), [ - { - "template": [ - {"invalid": "config"}, - # Config after invalid should still be set up - { - "unique_id": "listening-test-event", - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensors": { - "hello": { - "friendly_name": "Hello Name", - "unique_id": "hello_name-id", - "device_class": "battery", - "value_template": "{{ trigger.event.data.beer == 2 }}", - "entity_picture_template": "{{ '/local/dogs.png' }}", - "icon_template": "{{ 'mdi:pirate' }}", - "attribute_templates": { - "plus_one": "{{ trigger.event.data.beer + 1 }}" - }, - }, - }, - "binary_sensor": [ - { - "name": "via list", - "unique_id": "via_list-id", - "device_class": "battery", - "state": "{{ trigger.event.data.beer == 2 }}", - "picture": "{{ '/local/dogs.png' }}", - "icon": "{{ 'mdi:pirate' }}", - "attributes": { - "plus_one": "{{ trigger.event.data.beer + 1 }}", - "another": "{{ trigger.event.data.uno_mas or 1 }}", - }, - } - ], - }, - { - "trigger": [], - "binary_sensors": { - "bare_minimum": { - "value_template": "{{ trigger.event.data.beer == 1 }}" - }, - }, - }, - ], - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_trigger_entity( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test trigger entity works.""" - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") - assert state is not None - assert state.state == STATE_UNKNOWN - - state = hass.states.get("binary_sensor.bare_minimum") - assert state is not None - assert state.state == STATE_UNKNOWN - - context = Context() - hass.bus.async_fire("test_event", {"beer": 2}, context=context) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.hello_name") - assert state.state == STATE_ON - assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == "mdi:pirate" - assert state.attributes.get("entity_picture") == "/local/dogs.png" - assert state.attributes.get("plus_one") == 3 - assert state.context is context - - assert len(entity_registry.entities) == 2 - assert ( - entity_registry.entities["binary_sensor.hello_name"].unique_id - == "listening-test-event-hello_name-id" - ) - assert ( - entity_registry.entities["binary_sensor.via_list"].unique_id - == "listening-test-event-via_list-id" - ) - - state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_ON - assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == "mdi:pirate" - assert state.attributes.get("entity_picture") == "/local/dogs.png" - assert state.attributes.get("plus_one") == 3 - assert state.attributes.get("another") == 1 - assert state.context is context - - # Even if state itself didn't change, attributes might have changed - hass.bus.async_fire("test_event", {"beer": 2, "uno_mas": "si"}) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_ON - assert state.attributes.get("another") == "si" - - -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, + ( + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + { + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', }, - }, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_template_with_trigger_templated_delay_on(hass: HomeAssistant) -> None: - """Test binary sensor template with template delay on.""" - state = hass.states.get("binary_sensor.test") +@pytest.mark.parametrize( + ("beer_count", "first_state", "second_state", "final_state"), + [ + (2, STATE_UNKNOWN, STATE_ON, STATE_OFF), + (1, STATE_OFF, STATE_OFF, STATE_OFF), + (0, STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN), + (-1, STATE_UNAVAILABLE, STATE_UNAVAILABLE, STATE_UNAVAILABLE), + ], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_with_trigger_templated_auto_off( + hass: HomeAssistant, + beer_count: int, + first_state: str, + second_state: str, + final_state: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor template with template auto off.""" + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN context = Context() - hass.bus.async_fire("test_event", {"beer": 2}, context=context) + hass.bus.async_fire("test_event", {"beer": beer_count}, context=context) await hass.async_block_till_done() # State should still be unknown - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_UNKNOWN + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == first_state # Now wait for the on delay - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == second_state + + # Now wait for the auto-off + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == final_state + + +@pytest.mark.parametrize( + ("count", "style", "state_template", "extra_config"), + [ + ( + 1, + ConfigurationStyle.TRIGGER, + "{{ True }}", + { + "device_class": "motion", + "auto_off": '{{ ({ "seconds": 5 }) }}', + }, + ) + ], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_with_trigger_auto_off_cancel( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor template with template auto off.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {}, context=context) + await hass.async_block_till_done() + + # State should still be unknown + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + # Now wait for the on delay + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.bus.async_fire("test_event", {}, context=context) + await hass.async_block_till_done() + + # Now wait for the on delay + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON # Now wait for the auto-off - future = dt_util.utcnow() + timedelta(seconds=2) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( - ("config", "delay_state"), + ("count", "style", "extra_config", "attribute_value"), [ ( - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 10 }) }}', - }, - }, - }, - STATE_ON, - ), - ( - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer != 2 }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 10 }) }}', - }, - }, - }, - STATE_OFF, - ), + 1, + ConfigurationStyle.TRIGGER, + {"device_class": "motion"}, + "{{ states('sensor.test_attribute') }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("state_template", "attribute"), + [ + ("{{ True }}", "delay_on"), + ("{{ False }}", "delay_off"), + ("{{ True }}", "auto_off"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_trigger_with_negative_time_periods( + hass: HomeAssistant, attribute: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test binary sensor template with template negative time periods.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "-5") + await hass.async_block_till_done() + + assert f"Error rendering {attribute} template: " in caplog.text + + +@pytest.mark.parametrize( + ("count", "style", "extra_config", "attribute_value"), + [ + ( + 1, + ConfigurationStyle.TRIGGER, + {"device_class": "motion"}, + "{{ ({ 'seconds': 10 }) }}", + ) + ], +) +@pytest.mark.parametrize( + ("state_template", "attribute", "delay_state"), + [ + ("{{ trigger.event.data.beer == 2 }}", "delay_on", STATE_ON), + ("{{ trigger.event.data.beer != 2 }}", "delay_off", STATE_OFF), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_trigger_template_delay_with_multiple_triggers( - hass: HomeAssistant, delay_state: str + hass: HomeAssistant, delay_state: str, freezer: FrozenDateTimeFactory ) -> None: """Test trigger based binary sensor with multiple triggers occurring during the delay.""" - future = dt_util.utcnow() for _ in range(10): # State should still be unknown - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN hass.bus.async_fire("test_event", {"beer": 2}, context=Context()) await hass.async_block_till_done() - future += timedelta(seconds=1) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == delay_state -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "picture": "{{ '/local/dogs.png' }}", - "icon": "{{ 'mdi:pirate' }}", - "attributes": { - "plus_one": "{{ trigger.event.data.beer + 1 }}", - "another": "{{ trigger.event.data.uno_mas or 1 }}", - }, - }, - }, - }, - ], -) @pytest.mark.parametrize( ("restored_state", "initial_state", "initial_attributes"), [ @@ -1314,12 +1328,9 @@ async def test_trigger_template_delay_with_multiple_triggers( ) async def test_trigger_entity_restore_state( hass: HomeAssistant, - count, - domain, - config, - restored_state, - initial_state, - initial_attributes, + restored_state: str, + initial_state: str, + initial_attributes: list[str], ) -> None: """Test restoring trigger template binary sensor.""" @@ -1330,7 +1341,7 @@ async def test_trigger_entity_restore_state( } fake_state = State( - "binary_sensor.test", + TEST_ENTITY_ID, restored_state, restored_attributes, ) @@ -1338,18 +1349,23 @@ async def test_trigger_entity_restore_state( "auto_off_time": None, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + { + "device_class": "motion", + "picture": "{{ '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + "attributes": { + "plus_one": "{{ trigger.event.data.beer + 1 }}", + "another": "{{ trigger.event.data.uno_mas or 1 }}", + }, + }, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == initial_state for attr, value in restored_attributes.items(): if attr in initial_attributes: @@ -1361,7 +1377,7 @@ async def test_trigger_entity_restore_state( hass.bus.async_fire("test_event", {"beer": 2}) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON assert state.attributes["icon"] == "mdi:pirate" assert state.attributes["entity_picture"] == "/local/dogs.png" @@ -1369,40 +1385,16 @@ async def test_trigger_entity_restore_state( assert state.attributes["another"] == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, - }, - }, - ], -) @pytest.mark.parametrize("restored_state", [STATE_ON, STATE_OFF]) async def test_trigger_entity_restore_state_auto_off( hass: HomeAssistant, - count, - domain, - config, - restored_state, + restored_state: str, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State( - "binary_sensor.test", - restored_state, - {}, - ) + fake_state = State(TEST_ENTITY_ID, restored_state, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1410,18 +1402,15 @@ async def test_trigger_entity_restore_state_auto_off( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == restored_state # Now wait for the auto-off @@ -1429,38 +1418,18 @@ async def test_trigger_entity_restore_state_auto_off( await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, - }, - }, - ], -) async def test_trigger_entity_restore_state_auto_off_expired( - hass: HomeAssistant, count, domain, config, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State( - "binary_sensor.test", - STATE_ON, - {}, - ) + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1468,21 +1437,132 @@ async def test_trigger_entity_restore_state_auto_off_expired( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF +async def test_saving_auto_off( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test we restore state integration.""" + restored_attributes = { + "entity_picture": "/local/cats.png", + "icon": "mdi:ship", + "plus_one": 55, + } + + freezer.move_to("2022-02-02 02:02:00+00:00") + fake_extra_data = { + "auto_off_time": { + "__type": "", + "isoformat": "2022-02-02T02:02:02+00:00", + }, + } + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + "{{ True }}", + { + "device_class": "motion", + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', + "attributes": restored_attributes, + }, + ) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == TEST_ENTITY_ID + + for attr, value in restored_attributes.items(): + assert state["attributes"][attr] == value + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == fake_extra_data + + +async def test_trigger_entity_restore_invalid_auto_off_time_data( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test restoring trigger template binary sensor.""" + + freezer.move_to("2022-02-02 12:02:00+00:00") + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_extra_data = { + "auto_off_time": { + "_type": "", + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), + }, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + await async_mock_restore_state_shutdown_restart(hass) + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == fake_extra_data + + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + +async def test_trigger_entity_restore_invalid_auto_off_time_key( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test restoring trigger template binary sensor.""" + + freezer.move_to("2022-02-02 12:02:00+00:00") + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_extra_data = { + "auto_off_timex": { + "__type": "", + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), + }, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + await async_mock_restore_state_shutdown_restart(hass) + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert "auto_off_timex" in extra_data + assert extra_data == fake_extra_data + + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1520,3 +1600,16 @@ async def test_device_id( template_entity = entity_registry.async_get("binary_sensor.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the config flow preview.""" + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + binary_sensor.DOMAIN, + {"name": "My template", "state": "{{ 'on' }}"}, + ) + assert state["state"] == "on" diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 2c4e24ddf71..08104025582 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -8,7 +8,7 @@ from pytest_unordered import unordered from homeassistant import config_entries from homeassistant.components.template import DOMAIN, async_setup_entry -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr @@ -121,6 +121,44 @@ BINARY_SENSOR_OPTIONS = { }, {}, ), + ( + "cover", + {"state": "{{ states('cover.one') }}"}, + "open", + {"one": "open", "two": "closed"}, + {}, + { + "device_class": "garage", + "set_cover_position": [ + { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"position": "{{ position }}"}, + } + ], + }, + { + "device_class": "garage", + "set_cover_position": [ + { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"position": "{{ position }}"}, + } + ], + }, + {}, + ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + "on", + {"one": "on", "two": "off"}, + {}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + {}, + ), ( "image", {"url": "{{ states('sensor.one') }}"}, @@ -131,6 +169,26 @@ BINARY_SENSOR_OPTIONS = { {"verify_ssl": True}, {}, ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + "on", + {"one": "on", "two": "off"}, + {}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + {}, + ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + "locked", + {"one": "locked", "two": "unlocked"}, + {}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + {}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -181,6 +239,16 @@ BINARY_SENSOR_OPTIONS = { {}, {}, ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + "docked", + {"one": "docked", "two": "cleaning"}, + {}, + {"start": []}, + {"start": []}, + {}, + ), ], ) @pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") @@ -217,16 +285,14 @@ async def test_config_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type + availability = {"advanced_options": {"availability": "{{ True }}"}} + with patch( "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "name": "My template", - **state_template, - **extra_input, - }, + {"name": "My template", **state_template, **extra_input, **availability}, ) await hass.async_block_till_done() @@ -238,6 +304,7 @@ async def test_config_flow( "template_type": template_type, **state_template, **extra_options, + **availability, } assert len(mock_setup_entry.mock_calls) == 1 @@ -248,6 +315,7 @@ async def test_config_flow( "template_type": template_type, **state_template, **extra_options, + **availability, } state = hass.states.get(f"{template_type}.my_template") @@ -288,6 +356,18 @@ async def test_config_flow( {}, {}, ), + ( + "cover", + {"state": "{{ 'open' }}"}, + {"set_cover_position": []}, + {"set_cover_position": []}, + ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), ( "image", { @@ -296,6 +376,18 @@ async def test_config_flow( {"verify_ssl": True}, {"verify_ssl": True}, ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -332,6 +424,12 @@ async def test_config_flow( {"options": "{{ ['off', 'on', 'auto'] }}"}, {"options": "{{ ['off', 'on', 'auto'] }}"}, ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + {"start": []}, + {"start": []}, + ), ], ) async def test_config_flow_device( @@ -474,6 +572,26 @@ async def test_config_flow_device( }, "state", ), + ( + "cover", + {"state": "{{ states('cover.one') }}"}, + {"state": "{{ states('cover.two') }}"}, + ["open", "closed"], + {"one": "open", "two": "closed"}, + {"set_cover_position": []}, + {"set_cover_position": []}, + "state", + ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + {"state": "{{ states('fan.two') }}"}, + ["on", "off"], + {"one": "on", "two": "off"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + "state", + ), ( "image", { @@ -491,6 +609,26 @@ async def test_config_flow_device( }, "url", ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + {"state": "{{ states('light.two') }}"}, + ["on", "off"], + {"one": "on", "two": "off"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + "state", + ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"state": "{{ states('lock.two') }}"}, + ["locked", "unlocked"], + {"one": "locked", "two": "unlocked"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + "state", + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -551,6 +689,16 @@ async def test_config_flow_device( {}, "value_template", ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + {"state": "{{ states('vacuum.two') }}"}, + ["docked", "cleaning"], + {"one": "docked", "two": "cleaning"}, + {"start": []}, + {"start": []}, + "state", + ), ], ) @pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") @@ -675,7 +823,7 @@ async def test_options( "{{ float(states('sensor.one'), default='') + float(states('sensor.two'), default='') }}", {}, {"one": "30.0", "two": "20.0"}, - ["", STATE_UNAVAILABLE, "50.0"], + ["", STATE_UNKNOWN, "50.0"], [{}, {}], [["one", "two"], ["one", "two"]], ), @@ -695,6 +843,9 @@ async def test_config_flow_preview( """Test the config flow preview.""" client = await hass_ws_client(hass) + hass.states.async_set("binary_sensor.available", "on") + await hass.async_block_till_done() + input_entities = ["one", "two"] result = await hass.config_entries.flow.async_init( @@ -712,12 +863,22 @@ async def test_config_flow_preview( assert result["errors"] is None assert result["preview"] == "template" + availability = { + "advanced_options": { + "availability": "{{ is_state('binary_sensor.available', 'on') }}" + } + } + await client.send_json_auto_id( { "type": "template/start_preview", "flow_id": result["flow_id"], "flow_type": "config_flow", - "user_input": {"name": "My template", "state": state_template} + "user_input": { + "name": "My template", + "state": state_template, + **availability, + } | extra_user_input, } ) @@ -725,13 +886,16 @@ async def test_config_flow_preview( assert msg["success"] assert msg["result"] is None + entities = [f"{template_type}.{_id}" for _id in listeners[0]] + entities.append("binary_sensor.available") + msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes[0], "listeners": { "all": False, "domains": [], - "entities": unordered([f"{template_type}.{_id}" for _id in listeners[0]]), + "entities": unordered(entities), "time": False, }, "state": template_states[0], @@ -743,6 +907,9 @@ async def test_config_flow_preview( ) await hass.async_block_till_done() + entities = [f"{template_type}.{_id}" for _id in listeners[1]] + entities.append("binary_sensor.available") + for template_state in template_states[1:]: msg = await client.receive_json() assert msg["event"] == { @@ -752,14 +919,32 @@ async def test_config_flow_preview( "listeners": { "all": False, "domains": [], - "entities": unordered( - [f"{template_type}.{_id}" for _id in listeners[1]] - ), + "entities": unordered(entities), "time": False, }, "state": template_state, } - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 + + # Test preview availability. + hass.states.async_set("binary_sensor.available", "off") + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My template"} + | extra_attributes[0] + | extra_attributes[1], + "listeners": { + "all": False, + "domains": [], + "entities": unordered(entities), + "time": False, + }, + "state": STATE_UNAVAILABLE, + } + + assert len(hass.states.async_all()) == 3 EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of template')" @@ -1278,6 +1463,18 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "cover", + {"state": "{{ states('cover.one') }}"}, + {"set_cover_position": []}, + {"set_cover_position": []}, + ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), ( "image", { @@ -1287,6 +1484,18 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -1329,6 +1538,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + {"start": []}, + {"start": []}, + ), ], ) async def test_options_flow_change_device( diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 48f45d879cd..2a83967b048 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import cover, template from homeassistant.components.cover import ( @@ -32,9 +33,10 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_template_cover" TEST_ENTITY_ID = f"cover.{TEST_OBJECT_ID}" @@ -237,6 +239,7 @@ async def setup_position_cover( { TEST_OBJECT_ID: { **COVER_ACTIONS, + "set_cover_position": SET_COVER_POSITION, "position_template": position_template, } }, @@ -247,6 +250,7 @@ async def setup_position_cover( count, { **NAMED_COVER_ACTIONS, + "set_cover_position": SET_COVER_POSITION, "position": position_template, }, ) @@ -256,6 +260,7 @@ async def setup_position_cover( count, { **NAMED_COVER_ACTIONS, + "set_cover_position": SET_COVER_POSITION, "position": position_template, }, ) @@ -563,6 +568,7 @@ async def test_template_position( position: int | None, expected: str, caplog: pytest.LogCaptureFixture, + calls: list[ServiceCall], ) -> None: """Test the position_template attribute.""" hass.states.async_set(TEST_STATE_ENTITY_ID, CoverState.OPEN) @@ -578,6 +584,19 @@ async def test_template_position( assert state.state == expected assert "ValueError" not in caplog.text + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, "position": 10}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_position") == position + assert state.state == expected + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( @@ -609,11 +628,38 @@ async def test_template_position( ], ) @pytest.mark.usefixtures("setup_cover") -async def test_template_not_optimistic(hass: HomeAssistant) -> None: +async def test_template_not_optimistic( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: """Test the is_closed attribute.""" state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( @@ -1604,3 +1650,52 @@ async def test_empty_action_config( state.attributes["supported_features"] == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | supported_feature ) + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a cover from a config entry.""" + + hass.states.async_set( + "cover.test_state", + "open", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('cover.test_state') }}", + "set_cover_position": [], + "template_type": COVER_DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("cover.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + cover.DOMAIN, + {"name": "My template", "state": "{{ 'open' }}", "set_cover_position": []}, + ) + + assert state["state"] == CoverState.OPEN diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 67a85839982..8e98d8c94a7 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,9 +9,5 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None) - with pytest.raises(NotImplementedError): - _ = entity.referenced_blueprint - - with pytest.raises(NotImplementedError): - entity._render_script_variables() + with pytest.raises(TypeError): + _ = abstract_entity.AbstractTemplateEntity(hass, {}) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 708ad6bdecd..81486d75137 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import fan, template @@ -21,10 +22,11 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component from tests.components.fan import common +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_fan" TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" @@ -1833,3 +1835,139 @@ async def test_nested_unique_id( entry = entity_registry.async_get("fan.test_b") assert entry assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_sensor', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_fan") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(_STATE_TEST_SENSOR, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_sensor', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_fan") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + fan.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a fan from a config entry.""" + + hass.states.async_set( + "sensor.test_sensor", + "on", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_sensor') }}", + "turn_on": [], + "turn_off": [], + "template_type": fan.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("fan.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + fan.DOMAIN, + { + "name": "My template", + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ) + + assert state["state"] == STATE_ON diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py new file mode 100644 index 00000000000..574c764ba28 --- /dev/null +++ b/tests/components/template/test_helpers.py @@ -0,0 +1,344 @@ +"""The tests for template helpers.""" + +import pytest + +from homeassistant.components.template.alarm_control_panel import ( + LEGACY_FIELDS as ALARM_CONTROL_PANEL_LEGACY_FIELDS, +) +from homeassistant.components.template.binary_sensor import ( + LEGACY_FIELDS as BINARY_SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.button import StateButtonEntity +from homeassistant.components.template.cover import LEGACY_FIELDS as COVER_LEGACY_FIELDS +from homeassistant.components.template.fan import LEGACY_FIELDS as FAN_LEGACY_FIELDS +from homeassistant.components.template.helpers import ( + async_setup_template_platform, + rewrite_legacy_to_modern_config, + rewrite_legacy_to_modern_configs, +) +from homeassistant.components.template.light import LEGACY_FIELDS as LIGHT_LEGACY_FIELDS +from homeassistant.components.template.lock import LEGACY_FIELDS as LOCK_LEGACY_FIELDS +from homeassistant.components.template.sensor import ( + LEGACY_FIELDS as SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.switch import ( + LEGACY_FIELDS as SWITCH_LEGACY_FIELDS, +) +from homeassistant.components.template.vacuum import ( + LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.template import Template + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + LOCK_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LOCK_LEGACY_FIELDS, + "code_format_template", + "code_format", + "{{ 'some format' }}", + ), + ], +) +async def test_legacy_to_modern_config( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of single legacy template to modern template.""" + config = { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + altered_configs = rewrite_legacy_to_modern_config(hass, config, legacy_fields) + + assert { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } == altered_configs + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + ALARM_CONTROL_PANEL_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + BINARY_SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "position_template", + "position", + "{{ 100 }}", + ), + ( + COVER_LEGACY_FIELDS, + "tilt_template", + "tilt", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "direction_template", + "direction", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "oscillating_template", + "oscillating", + "{{ True }}", + ), + ( + FAN_LEGACY_FIELDS, + "percentage_template", + "percentage", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "preset_mode_template", + "preset_mode", + "{{ 'foo' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgb_template", + "rgb", + "{{ (255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbw_template", + "rgbw", + "{{ (255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbww_template", + "rgbww", + "{{ (255,255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_list_template", + "effect_list", + "{{ ['a', 'b'] }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_template", + "effect", + "{{ 'a' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "level_template", + "level", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "max_mireds_template", + "max_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "min_mireds_template", + "min_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "supports_transition_template", + "supports_transition", + "{{ True }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "temperature_template", + "temperature", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "white_value_template", + "white_value", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "hs_template", + "hs", + "{{ (255, 255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "color_template", + "hs", + "{{ (255, 255) }}", + ), + ( + SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + SWITCH_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "battery_level_template", + "battery_level", + "{{ 100 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "fan_speed_template", + "fan_speed", + "{{ 7 }}", + ), + ], +) +async def test_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } + ] == altered_configs + + +@pytest.mark.parametrize( + "legacy_fields", + [ + BINARY_SENSOR_LEGACY_FIELDS, + SENSOR_LEGACY_FIELDS, + ], +) +async def test_friendly_name_template_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, +) -> None: + """Test the conversion of friendly_name_tempalte in legacy template to modern template.""" + config = { + "foo": { + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + "friendly_name_template": "{{ 'foo bar' }}", + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + "name": Template("{{ 'foo bar' }}", hass), + } + ] == altered_configs + + +async def test_platform_not_ready( + hass: HomeAssistant, +) -> None: + """Test async_setup_template_platform raises PlatformNotReady when trigger object is None.""" + with pytest.raises(PlatformNotReady): + await async_setup_template_platform( + hass, + "button", + {}, + StateButtonEntity, + None, + None, + {"coordinator": None, "entities": []}, + ) diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index cab940d4c66..0d593da9fba 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -9,7 +9,7 @@ from homeassistant import config from homeassistant.components.template import DOMAIN from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -369,6 +369,7 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: async def test_change_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, config_entry_options: dict[str, str], config_user_input: dict[str, str], ) -> None: @@ -379,6 +380,19 @@ async def test_change_device( changed in the integration options. """ + def check_template_entities( + template_entity_id: str, + device_id: str | None = None, + ) -> None: + """Check that the template entity is linked to the correct device.""" + template_entity_ids: list[str] = [] + for template_entity in entity_registry.entities.get_entries_for_config_entry_id( + template_config_entry.entry_id + ): + template_entity_ids.append(template_entity.entity_id) + assert template_entity.device_id == device_id + assert template_entity_ids == [template_entity_id] + # Configure devices registry entry_device1 = MockConfigEntry() entry_device1.add_to_hass(hass) @@ -413,9 +427,14 @@ async def test_change_device( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the config entry has been added to the device 1 registry (current) - current_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id in current_device.config_entries + template_entity_id = f"{config_entry_options['template_type']}.my_template" + + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 1 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id1) # Change config options to use device 2 and reload the integration result = await hass.config_entries.options.async_init( @@ -427,13 +446,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 1 registry - previous_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id not in previous_device.config_entries - - # Confirm that the config entry has been added to the device 2 registry (current) - current_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id in current_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 2 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id2) # Change the config options to remove the device and reload the integration result = await hass.config_entries.options.async_init( @@ -445,9 +463,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 2 registry - previous_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id not in previous_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are not linked to any device + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, None) # Confirm that there is no device with the helper config entry assert ( diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index eaa1708aea7..e5d05cfa08f 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import light, template from homeassistant.components.light import ( @@ -17,7 +18,6 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) -from homeassistant.components.template.light import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -29,16 +29,19 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component +from tests.typing import WebSocketGenerator # Represent for light's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" +TEST_OBJECT_ID = "test_light" +TEST_ENTITY_ID = f"light.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "light.test_state" OPTIMISTIC_ON_OFF_LIGHT_CONFIG = { "turn_on": { @@ -289,127 +292,6 @@ TEST_UNIQUE_ID_CONFIG = { } -@pytest.mark.parametrize( - ("old_attr", "new_attr", "attr_template"), - [ - ( - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "rgb_template", - "rgb", - "{{ (255,255,255) }}", - ), - ( - "rgbw_template", - "rgbw", - "{{ (255,255,255,255) }}", - ), - ( - "rgbww_template", - "rgbww", - "{{ (255,255,255,255,255) }}", - ), - ( - "effect_list_template", - "effect_list", - "{{ ['a', 'b'] }}", - ), - ( - "effect_template", - "effect", - "{{ 'a' }}", - ), - ( - "level_template", - "level", - "{{ 255 }}", - ), - ( - "max_mireds_template", - "max_mireds", - "{{ 255 }}", - ), - ( - "min_mireds_template", - "min_mireds", - "{{ 255 }}", - ), - ( - "supports_transition_template", - "supports_transition", - "{{ True }}", - ), - ( - "temperature_template", - "temperature", - "{{ 255 }}", - ), - ( - "white_value_template", - "white_value", - "{{ 255 }}", - ), - ( - "hs_template", - "hs", - "{{ (255, 255) }}", - ), - ( - "color_template", - "hs", - "{{ (255, 255) }}", - ), - ], -) -async def test_legacy_to_modern_config( - hass: HomeAssistant, old_attr: str, new_attr: str, attr_template: str -) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "unique_id": "foo-bar-light", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - old_attr: attr_template, - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": { - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "turn_on": { - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "unique_id": "foo-bar-light", - new_attr: Template(attr_template, hass), - } - ] == altered_configs - - async def async_setup_legacy_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: @@ -2863,3 +2745,142 @@ async def test_effect_with_empty_action( """Test empty set_effect action.""" state = hass.states.get("light.test_template_light") assert state.attributes["supported_features"] == LightEntityFeature.EFFECT + + +@pytest.mark.parametrize( + ("count", "light_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('light.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_light") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + light.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "light_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('light.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "expected"), + [ + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_light") +async def test_not_optimistic(hass: HomeAssistant, expected: str) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + light.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a light from a config entry.""" + + hass.states.async_set( + "sensor.test_sensor", + "on", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_sensor') }}", + "turn_on": [], + "turn_off": [], + "template_type": light.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + light.DOMAIN, + { + "name": "My template", + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ) + + assert state["state"] == STATE_ON diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 94b0669acd1..6a4164fb802 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import setup from homeassistant.components import lock, template @@ -19,9 +20,10 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_template_lock" TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" @@ -307,19 +309,19 @@ async def test_template_state(hass: HomeAssistant) -> None: hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.test_template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.test_template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OPEN) await hass.async_block_till_done() - state = hass.states.get("lock.test_template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.OPEN @@ -888,7 +890,16 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( - "test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED] + "test_state", + [ + LockState.LOCKED, + LockState.UNLOCKED, + LockState.OPEN, + LockState.UNLOCKING, + LockState.LOCKING, + LockState.JAMMED, + LockState.OPENING, + ], ) @pytest.mark.usefixtures("setup_state_lock") async def test_lock_state(hass: HomeAssistant, test_state) -> None: @@ -1128,3 +1139,140 @@ async def test_emtpy_action_config(hass: HomeAssistant) -> None: state = hass.states.get("lock.test_template_lock") assert state.state == LockState.LOCKED + + +@pytest.mark.parametrize( + ("count", "lock_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "lock": [], + "unlock": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_lock") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED + + # Ensure Trigger template entities update. + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED + + +@pytest.mark.parametrize( + ("count", "lock_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_state', 'on') }}", + "lock": [], + "unlock": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_lock") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a lock from a config entry.""" + + hass.states.async_set( + "sensor.test_state", + LockState.LOCKED, + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_state') }}", + "lock": [], + "unlock": [], + "template_type": lock.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("lock.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + lock.DOMAIN, + { + "name": "My template", + "state": "{{ 'locked' }}", + "lock": [], + "unlock": [], + }, + ) + + assert state["state"] == LockState.LOCKED diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index a15ae1e46c0..f10664e0d5f 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -29,15 +29,17 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_ICON, CONF_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, async_capture_events +from tests.typing import WebSocketGenerator _TEST_OBJECT_ID = "template_number" _TEST_NUMBER = f"number.{_TEST_OBJECT_ID}" @@ -62,11 +64,11 @@ _VALUE_INPUT_NUMBER_CONFIG = { } TEST_STATE_ENTITY_ID = "number.test_state" - +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" TEST_STATE_TRIGGER = { "trigger": { "trigger": "state", - "entity_id": [TEST_STATE_ENTITY_ID], + "entity_id": [TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID], }, "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, "action": [ @@ -190,19 +192,6 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" - with assert_setup_component(0, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "number": { - "set_value": {"service": "script.set_value"}, - } - } - }, - ) - with assert_setup_component(0, "template"): assert await setup.async_setup_component( hass, @@ -577,6 +566,122 @@ async def test_device_id( assert template_entity.device_id == device_entry.id +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "set_value": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_number") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 4 + + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 2}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 2 + + +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "state": "{{ states('sensor.test_state') }}", + "optimistic": False, + "set_value": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_number") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "set_value": [], + "state": "{{ states('number.test_state') }}", + "availability": "{{ is_state('binary_sensor.test_availability', 'on') }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_number") +async def test_availability(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + hass.states.async_set(TEST_STATE_ENTITY_ID, "4.0") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 4 + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, "2.0") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 2 + + @pytest.mark.parametrize( ("count", "number_config"), [ @@ -608,3 +713,24 @@ async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None: state = hass.states.get(_TEST_NUMBER) assert float(state.state) == 4 + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + number.DOMAIN, + { + "name": "My template", + "min": 0.0, + "max": 100.0, + **TEST_REQUIRED, + }, + ) + + assert state["state"] == "0.0" diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5e29993f0f6..eda27f18100 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -28,26 +28,32 @@ from homeassistant.const import ( ATTR_ICON, CONF_ENTITY_ID, CONF_ICON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, async_capture_events +from tests.conftest import WebSocketGenerator _TEST_OBJECT_ID = "template_select" _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" TEST_STATE_ENTITY_ID = "select.test_state" - +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" TEST_STATE_TRIGGER = { "trigger": { "trigger": "state", - "entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID], + "entity_id": [ + _OPTION_INPUT_SELECT, + TEST_STATE_ENTITY_ID, + TEST_AVAILABILITY_ENTITY_ID, + ], }, "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, "action": [ @@ -201,20 +207,6 @@ async def test_multiple_configs(hass: HomeAssistant) -> None: async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" - with assert_setup_component(0, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "select": { - "select_option": {"service": "script.select_option"}, - "options": "{{ ['a', 'b'] }}", - } - } - }, - ) - with assert_setup_component(0, "select"): assert await setup.async_setup_component( hass, @@ -559,3 +551,150 @@ async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: state = hass.states.get(_TEST_SELECT) assert state.state == "a" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_select") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNKNOWN + + # Ensure Trigger template entities update. + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "yes"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "state": "{{ states('select.test_state') }}", + "optimistic": False, + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_select") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + # Ensure Trigger template entities update the options list + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + "state": "{{ states('select.test_state') }}", + "availability": "{{ is_state('binary_sensor.test_availability', 'on') }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_select") +async def test_availability(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + hass.states.async_set(TEST_STATE_ENTITY_ID, "test") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, "yes") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + select.DOMAIN, + {"name": "My template", **TEST_OPTIONS}, + ) + + assert state["state"] == "test" diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 56eaa120b20..9aba8511192 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -30,6 +30,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component from homeassistant.util import dt as dt_util +from .conftest import async_get_flow_preview_state + from tests.common import ( MockConfigEntry, assert_setup_component, @@ -37,6 +39,7 @@ from tests.common import ( async_fire_time_changed, mock_restore_cache_with_extra_data, ) +from tests.conftest import WebSocketGenerator TEST_NAME = "sensor.test_template_sensor" @@ -1138,7 +1141,7 @@ async def test_duplicate_templates(hass: HomeAssistant) -> None: "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "unique_id": "hello_name-id", "device_class": "battery", @@ -1357,7 +1360,7 @@ async def test_trigger_conditional_entity_invalid_condition( { "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "value_template": "{{ trigger.event.data.beer }}", "entity_picture_template": "{{ '/local/dogs.png' }}", @@ -1527,6 +1530,45 @@ async def test_trigger_entity_available(hass: HomeAssistant) -> None: assert state.state == "unavailable" +@pytest.mark.parametrize(("source_event_value"), [None, "None"]) +async def test_numeric_trigger_entity_set_unknown( + hass: HomeAssistant, source_event_value: str | None +) -> None: + """Test trigger entity state parsing with numeric sensors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Source", + "state": "{{ trigger.event.data.value }}", + }, + ], + }, + ], + }, + ) + await hass.async_block_till_done() + + hass.bus.async_fire("test_event", {"value": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.source") + assert state is not None + assert state.state == "1" + + hass.bus.async_fire("test_event", {"value": source_event_value}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.source") + assert state is not None + assert state.state == STATE_UNKNOWN + + async def test_trigger_entity_available_skips_state( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -2395,3 +2437,19 @@ async def test_device_id( template_entity = entity_registry.async_get("sensor.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + sensor.DOMAIN, + {"name": "My template", "state": "{{ 0.0 }}"}, + ) + + assert state["state"] == "0.0" diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index de6894c73a8..5a884160fe8 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -7,8 +7,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -16,14 +14,13 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State -from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import ( MockConfigEntry, @@ -38,8 +35,13 @@ TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "switch.test_state" TEST_EVENT_TRIGGER = { - "trigger": {"platform": "event", "event_type": "test_event"}, - "variables": {"type": "{{ trigger.event.data.type }}"}, + "triggers": [ + {"trigger": "event", "event_type": "test_event"}, + {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID]}, + ], + "variables": { + "type": "{{ trigger.event.data.type if trigger.event is defined else trigger.entity_id }}" + }, "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], } @@ -308,37 +310,6 @@ async def setup_single_attribute_optimistic_switch( ) -async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "value_template": "{{ 1 == 1 }}", - "unique_id": "foo-bar-switch", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - **SWITCH_ACTIONS, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": SWITCH_TURN_OFF, - "turn_on": SWITCH_TURN_ON, - "unique_id": "foo-bar-switch", - "state": Template("{{ 1 == 1 }}", hass), - } - ] == altered_configs - - @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) @pytest.mark.parametrize( "style", @@ -396,37 +367,15 @@ async def test_flow_preview( hass_ws_client: WebSocketGenerator, ) -> None: """Test the config flow preview.""" - client = await hass_ws_client(hass) - result = await hass.config_entries.flow.async_init( - template.DOMAIN, context={"source": SOURCE_USER} + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + switch.DOMAIN, + {"name": "My template", state_key: "{{ 'on' }}"}, ) - assert result["type"] is FlowResultType.MENU - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": SWITCH_DOMAIN}, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == SWITCH_DOMAIN - assert result["errors"] is None - assert result["preview"] == "template" - - await client.send_json_auto_id( - { - "type": "template/start_preview", - "flow_id": result["flow_id"], - "flow_type": "config_flow", - "user_input": {"name": "My template", state_key: "{{ 'on' }}"}, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] is None - - msg = await client.receive_json() - assert msg["event"]["state"] == "on" + assert state["state"] == STATE_ON @pytest.mark.parametrize( @@ -1268,3 +1217,90 @@ async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "switch_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('switch.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, + ], +) +@pytest.mark.usefixtures("setup_switch") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + switch.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "switch_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('switch.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "expected"), + [ + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_switch") +async def test_not_optimistic(hass: HomeAssistant, expected: str) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + switch.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index d66fc2710c9..7fe3870ae1e 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,12 +9,8 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None) + entity = template_entity.TemplateEntity(hass, {}, "something_unique") - with pytest.raises(ValueError, match="^hass cannot be None"): - entity.add_template_attribute("_hello", template.Template("Hello")) - - entity.hass = object() with pytest.raises(ValueError, match="^template.hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello", None)) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index ae65823309a..21592718551 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import template, vacuum from homeassistant.components.vacuum import ( @@ -14,14 +15,15 @@ from homeassistant.components.vacuum import ( from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component from tests.components.vacuum import common +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_vacuum" TEST_ENTITY_ID = f"vacuum.{TEST_OBJECT_ID}" @@ -587,6 +589,40 @@ async def test_battery_level_template( _verify(hass, STATE_UNKNOWN, expected) +@pytest.mark.parametrize( + ("count", "state_template", "extra_config", "attribute_template"), + [(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "battery_level_template"), + (ConfigurationStyle.MODERN, "battery_level"), + (ConfigurationStyle.TRIGGER, "battery_level"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_battery_level_template_repair( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test battery_level template raises issue.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + "template", f"deprecated_battery_level_{TEST_ENTITY_ID}" + ) + assert issue.domain == "template" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID + assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID + assert "Detected that integration 'template' is setting the" not in caplog.text + + @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ @@ -1153,3 +1189,212 @@ async def test_empty_action_config( assert state.attributes["supported_features"] == ( VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features ) + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + {"name": TEST_OBJECT_ID, "start": [], **TEMPLATE_VACUUM_ACTIONS}, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("service", "expected"), + [ + (vacuum.SERVICE_START, VacuumActivity.CLEANING), + (vacuum.SERVICE_PAUSE, VacuumActivity.PAUSED), + (vacuum.SERVICE_STOP, VacuumActivity.IDLE), + (vacuum.SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (vacuum.SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_assumed_optimistic( + hass: HomeAssistant, + service: str, + expected: VacuumActivity, + calls: list[ServiceCall], +) -> None: + """Test assumed optimistic.""" + + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('sensor.test_state') }}", + "start": [], + **TEMPLATE_VACUUM_ACTIONS, + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("service", "expected"), + [ + (vacuum.SERVICE_START, VacuumActivity.CLEANING), + (vacuum.SERVICE_PAUSE, VacuumActivity.PAUSED), + (vacuum.SERVICE_STOP, VacuumActivity.IDLE), + (vacuum.SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (vacuum.SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_optimistic_option( + hass: HomeAssistant, + service: str, + expected: VacuumActivity, + calls: list[ServiceCall], +) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.RETURNING) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('sensor.test_state') }}", + "start": [], + **TEMPLATE_VACUUM_ACTIONS, + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "service", + [ + vacuum.SERVICE_START, + vacuum.SERVICE_PAUSE, + vacuum.SERVICE_STOP, + vacuum.SERVICE_RETURN_TO_BASE, + vacuum.SERVICE_CLEAN_SPOT, + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_not_optimistic( + hass: HomeAssistant, + service: str, + calls: list[ServiceCall], +) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a vacuum from a config entry.""" + + hass.states.async_set( + "sensor.test_sensor", + "docked", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_sensor') }}", + "start": [], + "template_type": vacuum.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("vacuum.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + vacuum.DOMAIN, + { + "name": "My template", + "state": "{{ 'cleaning' }}", + "start": [], + }, + ) + + assert state["state"] == VacuumActivity.CLEANING diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 443b0aa6e77..7eac7ff28aa 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -15,6 +15,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, @@ -131,6 +132,7 @@ async def setup_weather( { "platform": "template", "name": "test", + "unique_id": "abc123", "attribution_template": "{{ states('sensor.attribution') }}", "condition_template": "sunny", "temperature_template": "{{ states('sensor.temperature') | float }}", @@ -608,6 +610,7 @@ SAVED_EXTRA_DATA = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -623,6 +626,7 @@ SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -790,6 +794,7 @@ async def test_trigger_action(hass: HomeAssistant) -> None: "wind_speed_template": "{{ my_variable + 1 }}", "wind_bearing_template": "{{ my_variable + 1 }}", "ozone_template": "{{ my_variable + 1 }}", + "uv_index_template": "{{ my_variable + 1 }}", "visibility_template": "{{ my_variable + 1 }}", "pressure_template": "{{ my_variable + 1 }}", "wind_gust_speed_template": "{{ my_variable + 1 }}", @@ -864,6 +869,7 @@ async def test_trigger_weather_services( assert state.attributes["wind_speed"] == 3.0 assert state.attributes["wind_bearing"] == 3.0 assert state.attributes["ozone"] == 3.0 + assert state.attributes["uv_index"] == 3.0 assert state.attributes["visibility"] == 3.0 assert state.attributes["pressure"] == 3.0 assert state.attributes["wind_gust_speed"] == 3.0 @@ -962,6 +968,7 @@ SAVED_EXTRA_DATA_MISSING_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -1041,6 +1048,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: "wind_speed_template": "{{ states('sensor.windspeed') }}", "wind_bearing_template": "{{ states('sensor.windbearing') }}", "ozone_template": "{{ states('sensor.ozone') }}", + "uv_index_template": "{{ states('sensor.uv_index') }}", "visibility_template": "{{ states('sensor.visibility') }}", "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", @@ -1063,6 +1071,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.uv_index", ATTR_WEATHER_UV_INDEX, 3.7), ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), diff --git a/tests/components/tesla_fleet/__init__.py b/tests/components/tesla_fleet/__init__.py index c51cd83ee66..a43ec14fc51 100644 --- a/tests/components/tesla_fleet/__init__.py +++ b/tests/components/tesla_fleet/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.tesla_fleet.const import CLIENT_ID, DOMAIN +from homeassistant.components.tesla_fleet.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,7 +28,7 @@ async def setup_platform( await async_import_client_credential( hass, DOMAIN, - ClientCredential(CLIENT_ID, "", "Home Assistant"), + ClientCredential("CLIENT_ID", "CLIENT_SECRET", "Home Assistant"), DOMAIN, ) diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index c482d33de86..7ce99965900 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '123456', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123456', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -50,7 +48,6 @@ 'LRWXF7EK4KC700000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'LRWXF7EK4KC700000', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -83,7 +79,6 @@ 'abd-123', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -116,7 +110,6 @@ 'bcd-234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -126,7 +119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '234', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index c251468edc4..f6268627be1 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -2356,7 +2356,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'version', + 'original_name': 'Version', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2369,7 +2369,7 @@ # name: test_sensors[sensor.energy_site_version-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', @@ -2382,7 +2382,7 @@ # name: test_sensors[sensor.energy_site_version-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index 4a8142a2d85..98806a27268 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -713,8 +713,11 @@ async def test_reauth_confirm_form(hass: HomeAssistant) -> None: ("domain", "expected_valid"), [ ("example.com", True), + ("exa-mple.com", True), ("test.example.com", True), + ("tes-t.example.com", True), ("sub.domain.example.org", True), + ("su-b.dom-ain.exam-ple.org", True), ("https://example.com", False), ("invalid-domain", False), ("", False), @@ -722,6 +725,8 @@ async def test_reauth_confirm_form(hass: HomeAssistant) -> None: ("example.", False), (".example.com", False), ("exam ple.com", False), + ("-example.com", False), + ("domain-.example.com", False), ], ) def test_is_valid_domain(domain: str, expected_valid: bool) -> None: diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 0152543e512..ffcc74d5587 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -14,6 +14,7 @@ from .const import ( ENERGY_HISTORY, LIVE_STATUS, METADATA, + METADATA_LEGACY, PRODUCTS, SITE_INFO, VEHICLE_DATA, @@ -53,9 +54,9 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_legacy(): """Mock Tesla Fleet Api products method.""" with patch( - "tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True - ) as mock_pre2021: - yield mock_pre2021 + "tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=METADATA_LEGACY + ) as mock_products: + yield mock_products @pytest.fixture(autouse=True) @@ -119,8 +120,17 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_add_listener(): +def mock_stream_listen(): """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStream.listen", + ) as mock_stream_listen: + yield mock_stream_listen + + +@pytest.fixture(autouse=True) +def mock_add_listener(): + """Mock Teslemetry Stream add listener method.""" with patch( "teslemetry_stream.TeslemetryStream.async_add_listener", ) as mock_add_listener: diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index b658c1e2271..7b671bbeaaa 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -20,6 +20,7 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +ENERGY_HISTORY_EMPTY = load_json_object_fixture("energy_history_empty.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} @@ -36,6 +37,32 @@ COMMAND_ERRORS = (COMMAND_REASON, COMMAND_NOREASON, COMMAND_ERROR, COMMAND_NOERR RESPONSE_OK = {"response": {}, "error": None} METADATA = { + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "vehicle_location", + "energy_device_data", + "energy_cmds", + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": True, + "access": True, + "polling": False, + "firmware": "2026.0.0", + "discounted": False, + "fleet_telemetry": "1.0.2", + "name": "Home Assistant", + } + }, +} +METADATA_LEGACY = { "uid": "abc-123", "region": "NA", "scopes": [ @@ -55,6 +82,9 @@ METADATA = { "access": True, "polling": True, "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } @@ -67,7 +97,10 @@ METADATA_NOSCOPE = { "proxy": False, "access": True, "polling": True, - "firmware": "2024.44.25", + "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } diff --git a/tests/components/teslemetry/fixtures/energy_history_empty.json b/tests/components/teslemetry/fixtures/energy_history_empty.json new file mode 100644 index 00000000000..cc54000115a --- /dev/null +++ b/tests/components/teslemetry/fixtures/energy_history_empty.json @@ -0,0 +1,8 @@ +{ + "response": { + "serial_number": "xxxxxx", + "period": "day", + "installation_time_zone": "Australia/Brisbane", + "time_series": null + } +} diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 06ec0a60434..2b920a0cfdc 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -240,102 +240,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Automatic blind spot camera', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_blind_spot_camera', - 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Automatic emergency braking off', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_emergency_braking_off', - 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,151 +286,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Blind spot collision warning chime', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'blind_spot_collision_warning_chime', - 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'BMS full charge', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'bms_full_charge_complete', - 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_brake_pedal', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Brake pedal', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'brake_pedal', - 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] @@ -578,55 +338,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_cellular-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_cellular', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cellular', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cellular', - 'unique_id': 'LRW3F7EK4NC700000-cellular', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_cellular-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_charge_cable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -673,103 +384,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Charge enable request', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_enable_request', - 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Charge port cold weather mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_port_cold_weather_mode', - 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] @@ -817,7 +432,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor[binary_sensor.test_dashcam-entry] @@ -869,390 +484,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'DC to DC converter', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'dc_dc_enable', - 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Defrost for preconditioning', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'defrost_for_preconditioning', - 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_drive_rail', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Drive rail', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'drive_rail', - 'unique_id': 'LRW3F7EK4NC700000-drive_rail', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Driver seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Driver seat occupied', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_occupied', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Emergency lane departure avoidance', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'emergency_lane_departure_avoidance', - 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_vehicle-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_european_vehicle', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'European vehicle', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'europe_vehicle', - 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Fast charger present', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'fast_charger_present', - 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1299,7 +530,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] @@ -1348,7 +579,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] @@ -1397,7 +628,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] @@ -1446,633 +677,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_gps_state', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'GPS state', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'gps_state', - 'unique_id': 'LRW3F7EK4NC700000-gps_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Guest mode enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'guest_mode_enabled', - 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_hazard_lights', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hazard lights', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_hazards_active', - 'unique_id': 'LRW3F7EK4NC700000-lights_hazards_active', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_high_beams', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'High beams', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_high_beams', - 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'High voltage interlock loop fault', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvil', - 'unique_id': 'LRW3F7EK4NC700000-hvil', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Homelink nearby', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'homelink_nearby', - 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'HVAC auto mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_auto_mode', - 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Located at favorite', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_favorite', - 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_home-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_located_at_home', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Located at home', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_home', - 'unique_id': 'LRW3F7EK4NC700000-located_at_home', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_home-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_located_at_work', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Located at work', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_work', - 'unique_id': 'LRW3F7EK4NC700000-located_at_work', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Offroad lightbar', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'offroad_lightbar_present', - 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Passenger seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'PIN to Drive enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'pin_to_drive_enabled', - 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_preconditioning-entry] @@ -2168,55 +773,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Rear display HVAC', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rear_display_hvac_enabled', - 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] @@ -2265,7 +822,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] @@ -2314,7 +871,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] @@ -2363,7 +920,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] @@ -2412,103 +969,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_remote_start', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Remote start', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'remote_start_enabled', - 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Right hand drive', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'right_hand_drive', - 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] @@ -2556,151 +1017,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Seat vent enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'seat_vent_enabled', - 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_service_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Service mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'service_mode', - 'unique_id': 'LRW3F7EK4NC700000-service_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_speed_limited', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Speed limited', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'speed_limit_mode', - 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_status-entry] @@ -2749,55 +1066,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Supercharger session trip planner', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'supercharger_session_trip_planner', - 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] @@ -3093,103 +1362,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wi-Fi', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wifi', - 'unique_id': 'LRW3F7EK4NC700000-wifi', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_wiper_heat', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Wiper heat', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wiper_heat_enabled', - 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3256,32 +1428,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3293,46 +1439,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] @@ -3349,20 +1456,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cellular-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_cable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3374,33 +1467,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] @@ -3413,7 +1480,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -3430,110 +1497,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_defrost_for_preconditioning-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3545,7 +1508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -3559,7 +1522,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -3573,7 +1536,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -3587,178 +1550,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hazard_lights-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_voltage_interlock_loop_fault-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hvac_auto_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_home-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_work-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -3784,20 +1576,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -3811,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -3825,7 +1604,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -3839,7 +1618,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -3853,33 +1632,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_remote_start-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -3892,46 +1645,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_seat_vent_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_speed_limited-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -3945,20 +1659,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] @@ -4044,33 +1745,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_wi_fi-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensors_connectivity[binary_sensor.test_cellular-state] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 1aa68b59ee3..11708be7e39 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -407,9 +407,8 @@ ]), 'max_temp': 40, 'min_temp': 30, - 'supported_features': , + 'supported_features': , 'target_temp_step': 5, - 'temperature': None, }), 'context': , 'entity_id': 'climate.test_cabin_overheat_protection', diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index f1011034d63..ee27bc9f0af 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '123456', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123456', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -50,7 +48,6 @@ 'LRW3F7EK4NC700000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'LRW3F7EK4NC700000', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -83,7 +79,6 @@ 'abd-123', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -116,7 +110,6 @@ 'bcd-234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -126,7 +119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '234', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 57a0f49d949..1db8cf9612f 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_charged-statealt] @@ -130,7 +130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_discharged-statealt] @@ -205,7 +205,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_exported-statealt] @@ -280,7 +280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] @@ -355,7 +355,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] @@ -430,7 +430,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] @@ -580,7 +580,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] @@ -655,7 +655,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] @@ -730,7 +730,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] @@ -805,7 +805,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.038', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] @@ -955,7 +955,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_generator_exported-statealt] @@ -1105,7 +1105,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported-statealt] @@ -1180,7 +1180,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] @@ -1255,7 +1255,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] @@ -1330,7 +1330,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] @@ -1405,7 +1405,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_imported-statealt] @@ -1555,7 +1555,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_exported-statealt] @@ -1630,7 +1630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_imported-statealt] @@ -1780,7 +1780,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.074', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_home_usage-statealt] @@ -2087,7 +2087,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_exported-statealt] @@ -2162,7 +2162,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_generated-statealt] diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 0f5588fe323..b3871c52420 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -23,6 +23,7 @@ async def test_binary_sensor( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" @@ -37,6 +38,7 @@ async def test_binary_sensor_refresh( entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 27bed45c51f..f6c158fbd80 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -273,7 +273,6 @@ async def test_climate_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index e3933931c9f..2ba6d391cfc 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -55,7 +55,6 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -67,6 +66,7 @@ async def test_cover_noscope( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_services( hass: HomeAssistant, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index ea0ee08e64f..7edabe9ec6f 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -49,7 +49,6 @@ async def test_device_tracker_noscope( entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, mock_vehicle_data: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index fb8eb79a918..5737a5ebe2c 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,11 +1,16 @@ """Test the Telemetry Diagnostics.""" +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.core import HomeAssistant from . import setup_platform +from tests.common import async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -14,10 +19,17 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Test diagnostics.""" entry = await setup_platform(hass) + # Wait for coordinator refresh + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index d2ef5c38893..00e8d54c9fe 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -1,6 +1,6 @@ """Test the Teslemetry init.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,10 +11,17 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) +from homeassistant.components.teslemetry.const import DOMAIN from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -72,6 +79,7 @@ async def test_vehicle_refresh_error( mock_vehicle_data: AsyncMock, side_effect: TeslaFleetError, state: ConfigEntryState, + mock_legacy: AsyncMock, ) -> None: """Test coordinator refresh with an error.""" mock_vehicle_data.side_effect = side_effect @@ -107,20 +115,7 @@ async def test_energy_site_refresh_error( assert entry.state is state -# Test Energy History Coordinator -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) -async def test_energy_history_refresh_error( - hass: HomeAssistant, - mock_energy_history: AsyncMock, - side_effect: TeslaFleetError, - state: ConfigEntryState, -) -> None: - """Test coordinator refresh with an error.""" - mock_energy_history.side_effect = side_effect - entry = await setup_platform(hass) - assert entry.state is state - - +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_vehicle_stream( hass: HomeAssistant, mock_add_listener: AsyncMock, @@ -135,7 +130,7 @@ async def test_vehicle_stream( assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.test_user_present") - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE mock_add_listener.send( { @@ -193,3 +188,94 @@ async def test_modern_no_poll( assert mock_vehicle_data.called is False freezer.tick(VEHICLE_INTERVAL) assert mock_vehicle_data.called is False + + +async def test_stale_device_removal( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_products: AsyncMock, +) -> None: + """Test removal of stale devices.""" + + # Setup the entry first to get a valid config_entry_id + entry = await setup_platform(hass) + + # Create a device that should be removed (with the valid entry_id) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "stale-vin")}, + manufacturer="Tesla", + name="Stale Vehicle", + ) + + # Verify the stale device exists + pre_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + stale_identifiers = { + identifier for device in pre_devices for identifier in device.identifiers + } + assert (DOMAIN, "stale-vin") in stale_identifiers + + # Update products with an empty response (no devices) and reload entry + with patch( + "tesla_fleet_api.teslemetry.Teslemetry.products", + return_value={"response": []}, + ): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Get updated devices after reload + post_devices = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + post_identifiers = { + identifier for device in post_devices for identifier in device.identifiers + } + + # Verify the stale device has been removed + assert (DOMAIN, "stale-vin") not in post_identifiers + + # Verify the device itself has been completely removed from the registry + # since it had no other config entries + updated_device = device_registry.async_get_device( + identifiers={(DOMAIN, "stale-vin")} + ) + assert updated_device is None + + +async def test_device_retention_during_reload( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_products: AsyncMock, +) -> None: + """Test that valid devices are retained during a config entry reload.""" + # Setup entry with normal devices + entry = await setup_platform(hass) + + # Get initial device count and identifiers + pre_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + pre_count = len(pre_devices) + pre_identifiers = { + identifier for device in pre_devices for identifier in device.identifiers + } + + # Make sure we have some devices + assert pre_count > 0 + + # Save the original identifiers to compare after reload + original_identifiers = pre_identifiers.copy() + + # Reload the config entry with the same products data + # The mock_products fixture will return the same data as during setup + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Verify device count and identifiers after reload match pre-reload + post_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + post_count = len(post_devices) + post_identifiers = { + identifier for device in post_devices for identifier in device.identifiers + } + + # Since the products data didn't change, we should have the same devices + assert post_count == pre_count + assert post_identifiers == original_identifiers diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index ab8f21ceda4..8b7a91cfe2c 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -55,7 +55,6 @@ async def test_media_player_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct without required scope.""" diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f50dc93bde4..e8f413433c1 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,6 +1,6 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -8,12 +8,13 @@ from syrupy.assertion import SnapshotAssertion from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import assert_entities, assert_entities_alt, setup_platform -from .const import VEHICLE_DATA_ALT +from .const import ENERGY_HISTORY_EMPTY, VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -25,14 +26,15 @@ async def test_sensors( entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() - # Force the vehicle to use polling - with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): - entry = await setup_platform(hass, [Platform.SENSOR]) + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) @@ -101,3 +103,28 @@ async def test_sensors_streaming( ): state = hass.states.get(entity_id) assert state.state == snapshot(name=f"{entity_id}-state") + + +async def test_energy_history_no_time_series( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_energy_history: AsyncMock, +) -> None: + """Test energy history coordinator when time_series is not a list.""" + # Mock energy history to return data without time_series as a list + + entry = await setup_platform(hass, [Platform.SENSOR]) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "sensor.energy_site_battery_discharged" + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + mock_energy_history.return_value = ENERGY_HISTORY_EMPTY + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index bcf5407999f..fecb8db0092 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -1,23 +1,36 @@ """Test the Teslemetry services.""" +from datetime import time from unittest.mock import patch import pytest from homeassistant.components.teslemetry.const import DOMAIN from homeassistant.components.teslemetry.services import ( + ATTR_DAYS_OF_WEEK, ATTR_DEPARTURE_TIME, ATTR_ENABLE, ATTR_END_OFF_PEAK_TIME, + ATTR_END_TIME, ATTR_GPS, + ATTR_ID, + ATTR_LOCATION, + ATTR_NAME, ATTR_OFF_PEAK_CHARGING_ENABLED, ATTR_OFF_PEAK_CHARGING_WEEKDAYS, + ATTR_ONE_TIME, ATTR_PIN, + ATTR_PRECONDITION_TIME, ATTR_PRECONDITIONING_ENABLED, ATTR_PRECONDITIONING_WEEKDAYS, + ATTR_START_TIME, ATTR_TIME, ATTR_TOU_SETTINGS, + SERVICE_ADD_CHARGE_SCHEDULE, + SERVICE_ADD_PRECONDITION_SCHEDULE, SERVICE_NAVIGATE_ATTR_GPS_REQUEST, + SERVICE_REMOVE_CHARGE_SCHEDULE, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, SERVICE_SET_SCHEDULED_CHARGING, SERVICE_SET_SCHEDULED_DEPARTURE, SERVICE_SPEED_LIMIT, @@ -75,23 +88,12 @@ async def test_services( { CONF_DEVICE_ID: vehicle_device, ATTR_ENABLE: True, - ATTR_TIME: "6:00", + ATTR_TIME: "06:00", # 6:00 AM }, blocking=True, ) set_scheduled_charging.assert_called_once() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_CHARGING, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - }, - blocking=True, - ) - with patch( "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_departure", return_value=COMMAND_OK, @@ -104,39 +106,15 @@ async def test_services( ATTR_ENABLE: True, ATTR_PRECONDITIONING_ENABLED: True, ATTR_PRECONDITIONING_WEEKDAYS: False, - ATTR_DEPARTURE_TIME: "6:00", + ATTR_DEPARTURE_TIME: "06:00", # 6:00 AM ATTR_OFF_PEAK_CHARGING_ENABLED: True, ATTR_OFF_PEAK_CHARGING_WEEKDAYS: False, - ATTR_END_OFF_PEAK_TIME: "5:00", + ATTR_END_OFF_PEAK_TIME: "05:00", # 5:00 AM }, blocking=True, ) set_scheduled_departure.assert_called_once() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_DEPARTURE, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - ATTR_PRECONDITIONING_ENABLED: True, - }, - blocking=True, - ) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_DEPARTURE, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - ATTR_OFF_PEAK_CHARGING_ENABLED: True, - }, - blocking=True, - ) - with patch( "tesla_fleet_api.teslemetry.Vehicle.set_valet_mode", return_value=COMMAND_OK, @@ -200,6 +178,112 @@ async def test_services( ) set_time_of_use.assert_called_once() + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_charge_schedule", + return_value=COMMAND_OK, + ) as add_charge_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, + ATTR_START_TIME: time(7, 0, 0), # 7:00 AM + ATTR_END_TIME: time(18, 0, 0), # 6:00 PM + ATTR_ONE_TIME: False, + ATTR_NAME: "Test Schedule", + }, + blocking=True, + ) + add_charge_schedule.assert_called_once() + + # Test add_charge_schedule with minimal required parameters + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_charge_schedule", + return_value=COMMAND_OK, + ) as add_charge_schedule_minimal: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + }, + blocking=True, + ) + add_charge_schedule_minimal.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remove_charge_schedule", + return_value=COMMAND_OK, + ) as remove_charge_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_ID: 123, + }, + blocking=True, + ) + remove_charge_schedule.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_precondition_schedule", + return_value=COMMAND_OK, + ) as add_precondition_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, + ATTR_PRECONDITION_TIME: time(7, 0, 0), # 7:00 AM + ATTR_ONE_TIME: False, + ATTR_NAME: "Test Precondition Schedule", + }, + blocking=True, + ) + add_precondition_schedule.assert_called_once() + + # Test add_precondition_schedule with minimal required parameters + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_precondition_schedule", + return_value=COMMAND_OK, + ) as add_precondition_schedule_minimal: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + ATTR_PRECONDITION_TIME: time(8, 0, 0), # 8:00 AM + }, + blocking=True, + ) + add_precondition_schedule_minimal.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remove_precondition_schedule", + return_value=COMMAND_OK, + ) as remove_precondition_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_ID: 123, + }, + blocking=True, + ) + remove_precondition_schedule.assert_called_once() + with ( patch( "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr index 8780f64bb09..ff298f97ecd 100644 --- a/tests/components/tessie/snapshots/test_update.ambr +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -45,7 +45,7 @@ 'installed_version': '2023.38.6', 'latest_version': '2023.44.30.4', 'release_summary': None, - 'release_url': None, + 'release_url': 'https://stats.tessie.com/versions/2023.44.30.4', 'skipped_version': None, 'supported_features': , 'title': None, diff --git a/tests/components/thermobeacon/__init__.py b/tests/components/thermobeacon/__init__.py index 32b6d823ec2..9b43e3b33f2 100644 --- a/tests/components/thermobeacon/__init__.py +++ b/tests/components/thermobeacon/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index 7ac593e6336..6971d72c460 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 599612ce0b7..fed35bc6502 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components import threshold from homeassistant.components.threshold.config_flow import ConfigFlowHandler from homeassistant.components.threshold.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -174,6 +175,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Set up entities, with backing devices and config entries run1_entry = _create_mock_entity("sensor", "initial") run2_entry = _create_mock_entity("sensor", "changed") + assert run1_entry.device_id != run2_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -186,23 +188,27 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: "name": "My threshold", "upper": None, }, - title="My integration", + title="My threshold", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(run1_entry) + assert config_entry.entry_id not in _get_device_config_entries(run1_entry) assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run1_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "entity_id": "sensor.changed"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(run1_entry) - assert config_entry.entry_id in _get_device_config_entries(run2_entry) + assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run2_entry.device_id async def test_device_cleaning( @@ -273,7 +279,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(threshold_config_entry.entry_id) @@ -288,7 +294,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -299,6 +305,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the threshold config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the threshold config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -315,7 +369,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -332,7 +386,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -359,7 +417,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -374,7 +432,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -407,7 +469,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert threshold_config_entry.entry_id not in sensor_device_2.config_entries @@ -424,11 +486,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is moved to the other device + # Check that the entity is linked to the other device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert threshold_config_entry.entry_id in sensor_device_2.config_entries + assert threshold_config_entry.entry_id not in sensor_device_2.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -453,7 +519,7 @@ async def test_async_handle_source_entity_new_entity_id( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -471,12 +537,87 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the threshold config entry is updated with the new entity ID assert threshold_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes threshold config entry from device.""" + + threshold_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": sensor_entity_entry.entity_id, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=1, + minor_version=1, + ) + threshold_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + assert threshold_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + assert threshold_config_entry.version == 1 + assert threshold_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test", + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index ffdf6a6251a..9e2620313a0 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '19264d2dffdbca32', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tile Inc.', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '01.12.14.0', 'via_device_id': None, }) diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 6e68b354087..d2db9b094f5 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -92,12 +92,11 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, 1, {"name with space": None}]) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/togrill/__init__.py b/tests/components/togrill/__init__.py new file mode 100644 index 00000000000..9e0d164ae2a --- /dev/null +++ b/tests/components/togrill/__init__.py @@ -0,0 +1,40 @@ +"""Tests for the ToGrill Bluetooth integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +TOGRILL_SERVICE_INFO = BluetoothServiceInfo( + name="Pro-05", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + +TOGRILL_SERVICE_INFO_NO_NAME = BluetoothServiceInfo( + name="", + address="00000000-0000-0000-0000-000000000002", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + +async def setup_entry( + hass: HomeAssistant, mock_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Make sure the device is available.""" + + with patch("homeassistant.components.togrill._PLATFORMS", platforms): + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/togrill/conftest.py b/tests/components/togrill/conftest.py new file mode 100644 index 00000000000..6b028ca5270 --- /dev/null +++ b/tests/components/togrill/conftest.py @@ -0,0 +1,96 @@ +"""Common fixtures for the ToGrill tests.""" + +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketNotify + +from homeassistant.components.togrill.const import CONF_PROBE_COUNT, DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_MODEL + +from . import TOGRILL_SERVICE_INFO + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_entry() -> MockConfigEntry: + """Create hass config fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: TOGRILL_SERVICE_INFO.address, + CONF_MODEL: "Pro-05", + CONF_PROBE_COUNT: 2, + }, + unique_id=TOGRILL_SERVICE_INFO.address, + ) + + +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.togrill.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.togrill.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mock]: + """Auto mock bluetooth.""" + + client_object = Mock(spec=Client) + client_object.mocked_notify = None + + async def _connect( + address: str, callback: Callable[[Packet], None] | None = None + ) -> Mock: + client_object.mocked_notify = callback + return client_object + + async def _disconnect() -> None: + pass + + async def _request(packet_type: type[Packet]) -> None: + if packet_type is PacketA0Notify: + client_object.mocked_notify(PacketA0Notify(0, 0, 0, 0, 0, False, 0, False)) + + async def _read(packet_type: type[PacketNotify]) -> PacketNotify: + if packet_type is PacketA0Notify: + return PacketA0Notify(0, 0, 0, 0, 0, False, 0, False) + raise NotImplementedError + + mock_client_class.connect.side_effect = _connect + client_object.request.side_effect = _request + client_object.read.side_effect = _read + client_object.disconnect.side_effect = _disconnect + client_object.is_connected = True + + return client_object + + +@pytest.fixture(autouse=True) +def mock_client_class() -> Generator[Mock]: + """Auto mock bluetooth.""" + + with ( + patch( + "homeassistant.components.togrill.config_flow.Client", autospec=True + ) as client_class, + patch("homeassistant.components.togrill.coordinator.Client", new=client_class), + ): + yield client_class diff --git a/tests/components/togrill/snapshots/test_event.ambr b/tests/components/togrill/snapshots/test_event.ambr new file mode 100644 index 00000000000..18b175e7513 --- /dev/null +++ b/tests/components/togrill/snapshots/test_event.ambr @@ -0,0 +1,721 @@ +# serializer version: 1 +# name: test_events[0][event.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[0][event.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': 'probe_acknowledge', + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 1', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- +# name: test_events[0][event.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[0][event.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': 'probe_acknowledge', + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 2', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- +# name: test_events[5][event.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[5][event.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': 'probe_alarm', + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 1', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- +# name: test_events[5][event.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[5][event.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': 'probe_alarm', + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 2', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- +# name: test_events[6][event.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[6][event.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': 'probe_disconnected', + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 1', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- +# name: test_events[6][event.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[6][event.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': 'probe_disconnected', + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 2', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- +# name: test_setup[no_data][event.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[no_data][event.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 1', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][event.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[no_data][event.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 2', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[non_event_packet][event.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[non_event_packet][event.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 1', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[non_event_packet][event.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[non_event_packet][event.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 2', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[non_known_message][event.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[non_known_message][event.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 1', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[non_known_message][event.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': '00000000-0000-0000-0000-000000000001_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[non_known_message][event.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'probe_acknowledge', + 'probe_alarm', + 'probe_disconnected', + ]), + 'friendly_name': 'Pro-05 Probe 2', + }), + 'context': , + 'entity_id': 'event.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/togrill/snapshots/test_init.ambr b/tests/components/togrill/snapshots/test_init.ambr new file mode 100644 index 00000000000..b461d103e73 --- /dev/null +++ b/tests/components/togrill/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_setup_device_present + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'bluetooth', + '00000000-0000-0000-0000-000000000001', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': 'Pro-05', + 'name': 'Pro-05', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': '0.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/togrill/snapshots/test_number.ambr b/tests/components/togrill/snapshots/test_number.ambr new file mode 100644 index 00000000000..639f2758c69 --- /dev/null +++ b/tests/components/togrill/snapshots/test_number.ambr @@ -0,0 +1,355 @@ +# serializer version: 1 +# name: test_setup[no_data][number.pro_05_alarm_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_alarm_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm interval', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_interval', + 'unique_id': '00000000-0000-0000-0000-000000000001_alarm_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_alarm_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pro-05 Alarm interval', + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_alarm_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[no_data][number.pro_05_target_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 1', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[no_data][number.pro_05_target_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][number.pro_05_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 2', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_alarm_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_alarm_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm interval', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_interval', + 'unique_id': '00000000-0000-0000-0000-000000000001_alarm_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_alarm_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pro-05 Alarm interval', + 'max': 15, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_alarm_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 1', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pro_05_target_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_target', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_target_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[one_probe_with_target_alarm][number.pro_05_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Target 2', + 'max': 250, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pro_05_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/togrill/snapshots/test_sensor.ambr b/tests/components/togrill/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bc55d831500 --- /dev/null +++ b/tests/components/togrill/snapshots/test_sensor.ambr @@ -0,0 +1,673 @@ +# serializer version: 1 +# name: test_setup[battery][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[battery][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/togrill/test_config_flow.py b/tests/components/togrill/test_config_flow.py new file mode 100644 index 00000000000..2620a88f7f2 --- /dev/null +++ b/tests/components/togrill/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test the ToGrill config flow.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.togrill.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import TOGRILL_SERVICE_INFO, TOGRILL_SERVICE_INFO_NO_NAME, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_selection( + hass: HomeAssistant, +) -> None: + """Test we can select a device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address + + +async def test_failed_connect( + hass: HomeAssistant, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test failure to connect result.""" + + mock_client_class.connect.side_effect = BleakError("Failed to connect") + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_failed_read( + hass: HomeAssistant, + mock_client: Mock, +) -> None: + """Test failure to read from device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_client.read.side_effect = BleakError("something went wrong") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_no_devices( + hass: HomeAssistant, +) -> None: + """Test missing device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_duplicate_setup( + hass: HomeAssistant, + mock_entry: MockConfigEntry, +) -> None: + """Test we can not setup a device again.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + await setup_entry(hass, mock_entry, []) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_bluetooth( + hass: HomeAssistant, +) -> None: + """Test bluetooth device discovery.""" + + # Inject the service info will trigger the flow to start + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN))) + + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address diff --git a/tests/components/togrill/test_event.py b/tests/components/togrill/test_event.py new file mode 100644 index 00000000000..932e6e93433 --- /dev/null +++ b/tests/components/togrill/test_event.py @@ -0,0 +1,69 @@ +"""Test events for ToGrill integration.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.packets import PacketA1Notify, PacketA5Notify + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.freeze_time("2023-10-21") +@pytest.mark.parametrize( + "packets", + [ + pytest.param([], id="no_data"), + pytest.param([PacketA1Notify([10, None])], id="non_event_packet"), + pytest.param([PacketA5Notify(probe=1, message=99)], id="non_known_message"), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test standard events.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.EVENT]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.freeze_time("2023-10-21") +@pytest.mark.parametrize( + "message", + list(PacketA5Notify.Message), +) +async def test_events( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + message, +) -> None: + """Test all possible events.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.EVENT]) + + mock_client.mocked_notify(PacketA5Notify(probe=1, message=message)) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) diff --git a/tests/components/togrill/test_init.py b/tests/components/togrill/test_init.py new file mode 100644 index 00000000000..7f441817176 --- /dev/null +++ b/tests/components/togrill/test_init.py @@ -0,0 +1,67 @@ +"""Test for initialization of ToGrill integration.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_setup_device_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that setup works with device present.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, TOGRILL_SERVICE_INFO.address)} + ) + assert device == snapshot + + +async def test_setup_device_not_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup succeeds if device is missing.""" + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_setup_device_failing( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup fails if device is not responding.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + mock_client.is_connected = False + mock_client.read.side_effect = BleakError("Failed to read data") + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/togrill/test_number.py b/tests/components/togrill/test_number.py new file mode 100644 index 00000000000..05ef6b49d07 --- /dev/null +++ b/tests/components/togrill/test_number.py @@ -0,0 +1,243 @@ +"""Test numbers for ToGrill integration.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.exceptions import BaseError +from togrill_bluetooth.packets import ( + PacketA0Notify, + PacketA6Write, + PacketA8Notify, + PacketA301Write, +) + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + "packets", + [ + pytest.param([], id="no_data"), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ), + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + PacketA8Notify(probe=2, alarm_type=None), + ], + id="one_probe_with_target_alarm", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the numbers.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.parametrize( + ("packets", "entity_id", "value", "write_packet"), + [ + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.pro_05_target_1", + 100.0, + PacketA301Write(probe=1, target=100), + id="probe", + ), + pytest.param( + [ + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ], + "number.pro_05_target_1", + 0.0, + PacketA301Write(probe=1, target=None), + id="probe_clear", + ), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ) + ], + "number.pro_05_alarm_interval", + 15, + PacketA6Write(temperature_unit=None, alarm_interval=15), + id="alarm_interval", + ), + ], +) +async def test_set_number( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, + entity_id, + value, + write_packet, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: value, + }, + target={ + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + mock_client.write.assert_any_call(write_packet) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + pytest.param( + BleakError("Some error"), + "Communication failed with the device", + id="bleak", + ), + pytest.param( + BaseError("Some error"), + "Data was rejected by device", + id="base", + ), + ], +) +async def test_set_number_write_error( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + error, + message, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + mock_client.mocked_notify( + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ) + mock_client.write.side_effect = error + + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.pro_05_target_1", + }, + blocking=True, + ) + + +async def test_set_number_disconnected( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the number set.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + + mock_client.mocked_notify( + PacketA8Notify( + probe=1, + alarm_type=PacketA8Notify.AlarmType.TEMPERATURE_TARGET, + temperature_1=50.0, + ), + ) + mock_client.is_connected = False + + with pytest.raises(HomeAssistantError, match=""): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.pro_05_target_1", + }, + blocking=True, + ) diff --git a/tests/components/togrill/test_sensor.py b/tests/components/togrill/test_sensor.py new file mode 100644 index 00000000000..d7662d483af --- /dev/null +++ b/tests/components/togrill/test_sensor.py @@ -0,0 +1,59 @@ +"""Test sensors for ToGrill integration.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + "packets", + [ + pytest.param([], id="no_data"), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ) + ], + id="battery", + ), + pytest.param([PacketA1Notify([10, None])], id="temp_data"), + pytest.param([PacketA1Notify([10])], id="temp_data_missing_probe"), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the sensors.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) diff --git a/tests/components/totalconnect/__init__.py b/tests/components/totalconnect/__init__.py index 180a00188cd..e7b358157cb 100644 --- a/tests/components/totalconnect/__init__.py +++ b/tests/components/totalconnect/__init__.py @@ -1 +1,13 @@ """Tests for the totalconnect component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py deleted file mode 100644 index 34d451ec0b8..00000000000 --- a/tests/components/totalconnect/common.py +++ /dev/null @@ -1,473 +0,0 @@ -"""Common methods used across tests for TotalConnect.""" - -from typing import Any -from unittest.mock import patch - -from total_connect_client import ArmingState, ResultCode, ZoneStatus, ZoneType - -from homeassistant.components.totalconnect.const import ( - AUTO_BYPASS, - CODE_REQUIRED, - CONF_USERCODES, - DOMAIN, -) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -LOCATION_ID = 123456 - -DEVICE_INFO_BASIC_1 = { - "DeviceID": "987654", - "DeviceName": "test", - "DeviceClassID": 1, - "DeviceSerialNumber": "987654321ABC", - "DeviceFlags": "PromptForUserCode=0,PromptForInstallerCode=0,PromptForImportSecuritySettings=0,AllowUserSlotEditing=0,CalCapable=1,CanBeSentToPanel=0,CanArmNightStay=0,CanSupportMultiPartition=0,PartitionCount=0,MaxPartitionCount=0,OnBoardingSupport=0,PartitionAdded=0,DuplicateUserSyncStatus=0,PanelType=8,PanelVariant=1,BLEDisarmCapable=0,ArmHomeSupported=0,DuplicateUserCodeCheck=1,CanSupportRapid=0,IsKeypadSupported=1,WifiEnrollmentSupported=0,IsConnectedPanel=0,ArmNightInSceneSupported=0,BuiltInCameraSettingsSupported=0,ZWaveThermostatScheduleDisabled=0,MultipleAuthorityLevelSupported=0,VideoOnPanelSupported=0,EnableBLEMode=0,IsPanelWiFiResetSupported=0,IsCompetitorClearBypass=0,IsNotReadyStateSupported=0,isArmStatusWithoutExitDelayNotSupported=0", - "SecurityPanelTypeID": None, - "DeviceSerialText": None, -} -DEVICE_LIST = [DEVICE_INFO_BASIC_1] - -LOCATION_INFO_BASIC_NORMAL = { - "LocationID": LOCATION_ID, - "LocationName": "test", - "SecurityDeviceID": "987654", - "PhotoURL": "http://www.example.com/some/path/to/file.jpg", - "LocationModuleFlags": "Security=1,Video=0,Automation=0,GPS=0,VideoPIR=0", - "DeviceList": {"DeviceInfoBasic": DEVICE_LIST}, -} - -LOCATIONS = {"LocationInfoBasic": [LOCATION_INFO_BASIC_NORMAL]} - -MODULE_FLAGS = "Some=0,Fake=1,Flags=2" - -USER = { - "UserID": "1234567", - "Username": "username", - "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", -} - -RESPONSE_SESSION_DETAILS = { - "ResultCode": ResultCode.SUCCESS.value, - "ResultData": "Success", - "SessionID": "12345", - "Locations": LOCATIONS, - "ModuleFlags": MODULE_FLAGS, - "UserInfo": USER, -} - -PARTITION_DISARMED = { - "PartitionID": "1", - "ArmingState": ArmingState.DISARMED, -} - -PARTITION_DISARMED2 = { - "PartitionID": "2", - "ArmingState": ArmingState.DISARMED, -} - -PARTITION_ARMED_STAY = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_STAY, -} - -PARTITION_ARMED_STAY2 = { - "PartitionID": "2", - "ArmingState": ArmingState.DISARMED, -} - -PARTITION_ARMED_AWAY = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_AWAY, -} - -PARTITION_ARMED_CUSTOM = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_CUSTOM_BYPASS, -} - -PARTITION_ARMED_NIGHT = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_STAY_NIGHT, -} - -PARTITION_ARMING = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMING, -} -PARTITION_DISARMING = { - "PartitionID": "1", - "ArmingState": ArmingState.DISARMING, -} - -PARTITION_TRIGGERED_POLICE = { - "PartitionID": "1", - "ArmingState": ArmingState.ALARMING, -} - -PARTITION_TRIGGERED_FIRE = { - "PartitionID": "1", - "ArmingState": ArmingState.ALARMING_FIRE_SMOKE, -} - -PARTITION_TRIGGERED_CARBON_MONOXIDE = { - "PartitionID": "1", - "ArmingState": ArmingState.ALARMING_CARBON_MONOXIDE, -} - -PARTITION_UNKNOWN = { - "PartitionID": "1", - "ArmingState": "99999", -} - - -PARTITION_INFO_DISARMED = [PARTITION_DISARMED, PARTITION_DISARMED2] -PARTITION_INFO_ARMED_STAY = [PARTITION_ARMED_STAY, PARTITION_ARMED_STAY2] -PARTITION_INFO_ARMED_AWAY = [PARTITION_ARMED_AWAY] -PARTITION_INFO_ARMED_CUSTOM = [PARTITION_ARMED_CUSTOM] -PARTITION_INFO_ARMED_NIGHT = [PARTITION_ARMED_NIGHT] -PARTITION_INFO_ARMING = [PARTITION_ARMING] -PARTITION_INFO_DISARMING = [PARTITION_DISARMING] -PARTITION_INFO_TRIGGERED_POLICE = [PARTITION_TRIGGERED_POLICE] -PARTITION_INFO_TRIGGERED_FIRE = [PARTITION_TRIGGERED_FIRE] -PARTITION_INFO_TRIGGERED_CARBON_MONOXIDE = [PARTITION_TRIGGERED_CARBON_MONOXIDE] -PARTITION_INFO_UNKNOWN = [PARTITION_UNKNOWN] - -PARTITIONS_DISARMED = {"PartitionInfo": PARTITION_INFO_DISARMED} -PARTITIONS_ARMED_STAY = {"PartitionInfo": PARTITION_INFO_ARMED_STAY} -PARTITIONS_ARMED_AWAY = {"PartitionInfo": PARTITION_INFO_ARMED_AWAY} -PARTITIONS_ARMED_CUSTOM = {"PartitionInfo": PARTITION_INFO_ARMED_CUSTOM} -PARTITIONS_ARMED_NIGHT = {"PartitionInfo": PARTITION_INFO_ARMED_NIGHT} -PARTITIONS_ARMING = {"PartitionInfo": PARTITION_INFO_ARMING} -PARTITIONS_DISARMING = {"PartitionInfo": PARTITION_INFO_DISARMING} -PARTITIONS_TRIGGERED_POLICE = {"PartitionInfo": PARTITION_INFO_TRIGGERED_POLICE} -PARTITIONS_TRIGGERED_FIRE = {"PartitionInfo": PARTITION_INFO_TRIGGERED_FIRE} -PARTITIONS_TRIGGERED_CARBON_MONOXIDE = { - "PartitionInfo": PARTITION_INFO_TRIGGERED_CARBON_MONOXIDE -} -PARTITIONS_UNKNOWN = {"PartitionInfo": PARTITION_INFO_UNKNOWN} - -ZONE_NORMAL = { - "ZoneID": "1", - "ZoneDescription": "Security", - "ZoneStatus": ZoneStatus.FAULT, - "ZoneTypeId": ZoneType.SECURITY, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_2 = { - "ZoneID": "2", - "ZoneDescription": "Fire", - "ZoneStatus": ZoneStatus.LOW_BATTERY, - "ZoneTypeId": ZoneType.FIRE_SMOKE, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_3 = { - "ZoneID": "3", - "ZoneDescription": "Gas", - "ZoneStatus": ZoneStatus.TAMPER, - "ZoneTypeId": ZoneType.CARBON_MONOXIDE, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_4 = { - "ZoneID": "4", - "ZoneDescription": "Motion", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.INTERIOR_FOLLOWER, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_5 = { - "ZoneID": "5", - "ZoneDescription": "Medical", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.PROA7_MEDICAL, - "PartitionId": "1", - "CanBeBypassed": 0, -} -# 99 is an unknown ZoneType -ZONE_6 = { - "ZoneID": "6", - "ZoneDescription": "Unknown", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": 99, - "PartitionId": "1", - "CanBeBypassed": 0, -} - -ZONE_7 = { - "ZoneID": 7, - "ZoneDescription": "Temperature", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.MONITOR, - "PartitionId": "1", - "CanBeBypassed": 0, -} - -# ZoneType security that cannot be bypassed is a Button on the alarm panel -ZONE_8 = { - "ZoneID": 8, - "ZoneDescription": "Button", - "ZoneStatus": ZoneStatus.FAULT, - "ZoneTypeId": ZoneType.SECURITY, - "PartitionId": "1", - "CanBeBypassed": 0, -} - - -ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6, ZONE_7] -ZONES = {"ZoneInfo": ZONE_INFO} - -METADATA_DISARMED = { - "Partitions": PARTITIONS_DISARMED, - "Zones": ZONES, - "PromptForImportSecuritySettings": False, - "IsInACLoss": False, - "IsCoverTampered": False, - "Bell1SupervisionFailure": False, - "Bell2SupervisionFailure": False, - "IsInLowBattery": False, -} - -METADATA_ARMED_STAY = METADATA_DISARMED.copy() -METADATA_ARMED_STAY["Partitions"] = PARTITIONS_ARMED_STAY - -METADATA_ARMED_AWAY = METADATA_DISARMED.copy() -METADATA_ARMED_AWAY["Partitions"] = PARTITIONS_ARMED_AWAY - -METADATA_ARMED_CUSTOM = METADATA_DISARMED.copy() -METADATA_ARMED_CUSTOM["Partitions"] = PARTITIONS_ARMED_CUSTOM - -METADATA_ARMED_NIGHT = METADATA_DISARMED.copy() -METADATA_ARMED_NIGHT["Partitions"] = PARTITIONS_ARMED_NIGHT - -METADATA_ARMING = METADATA_DISARMED.copy() -METADATA_ARMING["Partitions"] = PARTITIONS_ARMING - -METADATA_DISARMING = METADATA_DISARMED.copy() -METADATA_DISARMING["Partitions"] = PARTITIONS_DISARMING - -METADATA_TRIGGERED_POLICE = METADATA_DISARMED.copy() -METADATA_TRIGGERED_POLICE["Partitions"] = PARTITIONS_TRIGGERED_POLICE - -METADATA_TRIGGERED_FIRE = METADATA_DISARMED.copy() -METADATA_TRIGGERED_FIRE["Partitions"] = PARTITIONS_TRIGGERED_FIRE - -METADATA_TRIGGERED_CARBON_MONOXIDE = METADATA_DISARMED.copy() -METADATA_TRIGGERED_CARBON_MONOXIDE["Partitions"] = PARTITIONS_TRIGGERED_CARBON_MONOXIDE - -METADATA_UNKNOWN = METADATA_DISARMED.copy() -METADATA_UNKNOWN["Partitions"] = PARTITIONS_UNKNOWN - -RESPONSE_DISARMED = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_DISARMED, - "ArmingState": ArmingState.DISARMED, -} -RESPONSE_ARMED_STAY = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_STAY, - "ArmingState": ArmingState.ARMED_STAY, -} -RESPONSE_ARMED_AWAY = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_AWAY, - "ArmingState": ArmingState.ARMED_AWAY, -} -RESPONSE_ARMED_CUSTOM = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_CUSTOM, - "ArmingState": ArmingState.ARMED_CUSTOM_BYPASS, -} -RESPONSE_ARMED_NIGHT = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_NIGHT, - "ArmingState": ArmingState.ARMED_STAY_NIGHT, -} -RESPONSE_ARMING = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMING, - "ArmingState": ArmingState.ARMING, -} -RESPONSE_DISARMING = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_DISARMING, - "ArmingState": ArmingState.DISARMING, -} -RESPONSE_TRIGGERED_POLICE = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_TRIGGERED_POLICE, - "ArmingState": ArmingState.ALARMING, -} -RESPONSE_TRIGGERED_FIRE = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_TRIGGERED_FIRE, - "ArmingState": ArmingState.ALARMING_FIRE_SMOKE, -} -RESPONSE_TRIGGERED_CARBON_MONOXIDE = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_TRIGGERED_CARBON_MONOXIDE, - "ArmingState": ArmingState.ALARMING_CARBON_MONOXIDE, -} -RESPONSE_UNKNOWN = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_UNKNOWN, - "ArmingState": ArmingState.DISARMED, -} - -RESPONSE_ARM_SUCCESS = {"ResultCode": ResultCode.ARM_SUCCESS.value} -RESPONSE_ARM_FAILURE = {"ResultCode": ResultCode.COMMAND_FAILED.value} -RESPONSE_DISARM_SUCCESS = {"ResultCode": ResultCode.DISARM_SUCCESS.value} -RESPONSE_DISARM_FAILURE = { - "ResultCode": ResultCode.COMMAND_FAILED.value, - "ResultData": "Command Failed", -} -RESPONSE_USER_CODE_INVALID = { - "ResultCode": ResultCode.USER_CODE_INVALID.value, - "ResultData": "testing user code invalid", -} -RESPONSE_SUCCESS = {"ResultCode": ResultCode.SUCCESS.value} -RESPONSE_ZONE_BYPASS_SUCCESS = { - "ResultCode": ResultCode.SUCCESS.value, - "ResultData": "None", -} -RESPONSE_ZONE_BYPASS_FAILURE = { - "ResultCode": ResultCode.FAILED_TO_BYPASS_ZONE.value, - "ResultData": "None", -} - -USERNAME = "username@me.com" -PASSWORD = "password" -USERCODES = {LOCATION_ID: "7890"} -CONFIG_DATA = { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_USERCODES: USERCODES, -} -CONFIG_DATA_NO_USERCODES = {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} - -OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} -OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} - -PARTITION_DETAILS_1 = { - "PartitionID": "1", - "ArmingState": ArmingState.DISARMED.value, - "PartitionName": "Test1", -} - -PARTITION_DETAILS_2 = { - "PartitionID": "2", - "ArmingState": ArmingState.DISARMED.value, - "PartitionName": "Test2", -} - -PARTITION_DETAILS = {"PartitionDetails": [PARTITION_DETAILS_1, PARTITION_DETAILS_2]} -RESPONSE_PARTITION_DETAILS = { - "ResultCode": ResultCode.SUCCESS.value, - "ResultData": "testing partition details", - "PartitionsInfoList": PARTITION_DETAILS, -} - -ZONE_DETAILS_NORMAL = { - "PartitionId": "1", - "Batterylevel": "-1", - "Signalstrength": "-1", - "zoneAdditionalInfo": None, - "ZoneID": "1", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.SECURITY, - "CanBeBypassed": 1, - "ZoneFlags": None, -} - -ZONE_STATUS_INFO = [ZONE_DETAILS_NORMAL] -ZONE_DETAILS = {"ZoneStatusInfoWithPartitionId": ZONE_STATUS_INFO} -ZONE_DETAIL_STATUS = {"Zones": ZONE_DETAILS} - -RESPONSE_GET_ZONE_DETAILS_SUCCESS = { - "ResultCode": 0, - "ResultData": "Success", - "ZoneStatus": ZONE_DETAIL_STATUS, -} - -TOTALCONNECT_REQUEST = ( - "homeassistant.components.totalconnect.TotalConnectClient.request" -) -TOTALCONNECT_GET_CONFIG = ( - "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" -) -TOTALCONNECT_REQUEST_TOKEN = ( - "homeassistant.components.totalconnect.TotalConnectClient._request_token" -) - - -async def setup_platform( - hass: HomeAssistant, platform: Any, code_required: bool = False -) -> MockConfigEntry: - """Set up the TotalConnect platform.""" - # first set up a config entry and add it to hass - if code_required: - mock_entry = MockConfigEntry( - domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA_CODE_REQUIRED - ) - else: - mock_entry = MockConfigEntry( - domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA - ) - mock_entry.add_to_hass(hass) - - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - ] - - with ( - patch("homeassistant.components.totalconnect.PLATFORMS", [platform]), - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - ): - assert await async_setup_component(hass, DOMAIN, {}) - assert mock_request.call_count == 5 - await hass.async_block_till_done() - - return mock_entry - - -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: - """Set up the TotalConnect integration.""" - # first set up a config entry and add it to hass - mock_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA) - mock_entry.add_to_hass(hass) - - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - ] - - with ( - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - ): - await hass.config_entries.async_setup(mock_entry.entry_id) - assert mock_request.call_count == 5 - await hass.async_block_till_done() - - return mock_entry diff --git a/tests/components/totalconnect/conftest.py b/tests/components/totalconnect/conftest.py new file mode 100644 index 00000000000..803fc052129 --- /dev/null +++ b/tests/components/totalconnect/conftest.py @@ -0,0 +1,249 @@ +"""Configure py.test.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from total_connect_client import ArmingState, TotalConnectClient +from total_connect_client.device import TotalConnectDevice +from total_connect_client.location import TotalConnectLocation +from total_connect_client.partition import TotalConnectPartition +from total_connect_client.user import TotalConnectUser +from total_connect_client.zone import TotalConnectZone, ZoneStatus, ZoneType + +from homeassistant.components.totalconnect.const import ( + AUTO_BYPASS, + CODE_REQUIRED, + CONF_USERCODES, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CODE, LOCATION_ID, PASSWORD, USERCODES, USERNAME + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +def create_mock_zone( + identifier: int, + partition: str, + description: str, + status: ZoneStatus, + zone_type_id: int, + can_be_bypassed: bool, + battery_level: int, + signal_strength: int, + sensor_serial_number: str | None, + loop_number: int | None, + response_type: str | None, + alarm_report_state: str | None, + supervision_type: str | None, + chime_state: str | None, + device_type: str | None, +) -> AsyncMock: + """Create a mock TotalConnectZone.""" + zone = AsyncMock(spec=TotalConnectZone, autospec=True) + zone.zoneid = identifier + zone.partition = partition + zone.description = description + zone.status = status + zone.zone_type_id = zone_type_id + zone.can_be_bypassed = can_be_bypassed + zone.battery_level = battery_level + zone.signal_strength = signal_strength + zone.sensor_serial_number = sensor_serial_number + zone.loop_number = loop_number + zone.response_type = response_type + zone.alarm_report_state = alarm_report_state + zone.supervision_type = supervision_type + zone.chime_state = chime_state + zone.device_type = device_type + zone.is_type_security.return_value = zone_type_id in ( + ZoneType.SECURITY, + ZoneType.ENTRY_EXIT1, + ZoneType.ENTRY_EXIT2, + ZoneType.PERIMETER, + ZoneType.INTERIOR_FOLLOWER, + ZoneType.TROUBLE_ALARM, + ZoneType.SILENT_24HR, + ZoneType.AUDIBLE_24HR, + ZoneType.INTERIOR_DELAY, + ZoneType.LYRIC_LOCAL_ALARM, + ZoneType.PROA7_GARAGE_MONITOR, + ) + zone.is_type_button.return_value = ( + zone.is_type_security.return_value and not can_be_bypassed + ) or zone_type_id in ( + ZoneType.PROA7_MEDICAL, + ZoneType.AUDIBLE_24HR, + ZoneType.SILENT_24HR, + ZoneType.RF_ARM_STAY, + ZoneType.RF_ARM_AWAY, + ZoneType.RF_DISARM, + ) + return zone + + +def create_mock_zone_from_dict( + zone_data: dict[str, Any], +) -> AsyncMock: + """Create a mock TotalConnectZone from a dictionary.""" + return create_mock_zone( + zone_data["ZoneID"], + zone_data["PartitionId"], + zone_data["ZoneDescription"], + ZoneStatus(zone_data["ZoneStatus"]), + zone_data["ZoneTypeId"], + zone_data["CanBeBypassed"], + zone_data.get("Batterylevel"), + zone_data.get("Signalstrength"), + (zone_data["zoneAdditionalInfo"] or {}).get("SensorSerialNumber"), + (zone_data["zoneAdditionalInfo"] or {}).get("LoopNumber"), + (zone_data["zoneAdditionalInfo"] or {}).get("ResponseType"), + (zone_data["zoneAdditionalInfo"] or {}).get("AlarmReportState"), + (zone_data["zoneAdditionalInfo"] or {}).get("ZoneSupervisionType"), + (zone_data["zoneAdditionalInfo"] or {}).get("ChimeState"), + (zone_data["zoneAdditionalInfo"] or {}).get("DeviceType"), + ) + + +@pytest.fixture +def mock_partition() -> TotalConnectPartition: + """Create a mock TotalConnectPartition.""" + partition = AsyncMock(spec=TotalConnectPartition, autospec=True) + partition.partitionid = 1 + partition.name = "Test1" + partition.is_stay_armed = False + partition.is_fire_armed = False + partition.is_fire_enabled = False + partition.is_common_armed = False + partition.is_common_enabled = False + partition.is_locked = False + partition.is_new_partition = False + partition.is_night_stay_enabled = 0 + partition.exit_delay_timer = 0 + partition.arming_state = ArmingState.DISARMED + return partition + + +@pytest.fixture +def mock_partition_2() -> TotalConnectPartition: + """Create a mock TotalConnectPartition.""" + partition = AsyncMock(spec=TotalConnectPartition, autospec=True) + partition.partitionid = 2 + partition.name = "Test2" + partition.is_stay_armed = False + partition.is_fire_armed = False + partition.is_fire_enabled = False + partition.is_common_armed = False + partition.is_common_enabled = False + partition.is_locked = False + partition.is_new_partition = False + partition.is_night_stay_enabled = 0 + partition.exit_delay_timer = 0 + partition.arming_state = ArmingState.DISARMED + return partition + + +@pytest.fixture +def mock_location( + mock_partition: AsyncMock, mock_partition_2: AsyncMock +) -> TotalConnectLocation: + """Create a mock TotalConnectLocation.""" + location = AsyncMock(spec=TotalConnectLocation, autospec=True) + location.location_id = LOCATION_ID + location.location_name = "Test Location" + location.security_device_id = 7654321 + location.set_usercode.return_value = True + location.partitions = {1: mock_partition, 2: mock_partition_2} + location.devices = { + 7654321: TotalConnectDevice(load_json_object_fixture("device_1.json", DOMAIN)) + } + location.zones = { + z["ZoneID"]: create_mock_zone_from_dict(z) + for z in load_json_array_fixture("zones.json", DOMAIN) + } + location.is_low_battery.return_value = False + location.is_cover_tampered.return_value = False + location.is_ac_loss.return_value = False + location.arming_state = ArmingState.DISARMED + location._module_flags = { + "can_bypass_zones": True, + "can_clear_bypass": True, + "can_set_usercodes": True, + } + location.ac_loss = False + location.low_battery = False + location.auto_bypass_low_battery = False + location.cover_tampered = False + return location + + +@pytest.fixture +def mock_client(mock_location: TotalConnectLocation) -> Generator[TotalConnectClient]: + """Mock a TotalConnectClient for testing.""" + with ( + patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.totalconnect.TotalConnectClient", new=mock_client + ), + ): + client = mock_client.return_value + client.get_number_locations.return_value = 1 + client.locations = {mock_location.location_id: mock_location} + client.usercodes = {mock_location.location_id: CODE} + client.auto_bypass_low_battery = False + client._module_flags = {} + client.retry_delay = 0 + client._invalid_credentials = False + user_mock = AsyncMock(spec=TotalConnectUser, autospec=True) + user_mock._master_user = True + user_mock._user_admin = True + user_mock._config_admin = True + user_mock.security_problem.return_value = False + user_mock._features = { + "can_set_usercodes": True, + "can_bypass_zones": True, + "can_clear_bypass": True, + } + setattr(client, "_user", user_mock) + yield client + + +@pytest.fixture +def code_required() -> bool: + """Return whether a code is required.""" + return False + + +@pytest.fixture +def mock_config_entry(code_required: bool) -> MockConfigEntry: + """Create a mock config entry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_USERCODES: USERCODES, + }, + options={AUTO_BYPASS: False, CODE_REQUIRED: code_required}, + unique_id=USERNAME, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry for TotalConnect.""" + with patch( + "homeassistant.components.totalconnect.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup diff --git a/tests/components/totalconnect/const.py b/tests/components/totalconnect/const.py new file mode 100644 index 00000000000..60024c21011 --- /dev/null +++ b/tests/components/totalconnect/const.py @@ -0,0 +1,8 @@ +"""Constants for the Total Connect tests.""" + +LOCATION_ID = 1234567 +CODE = "7890" + +USERNAME = "username@me.com" +PASSWORD = "password" +USERCODES = {LOCATION_ID: "7890"} diff --git a/tests/components/totalconnect/fixtures/device_1.json b/tests/components/totalconnect/fixtures/device_1.json new file mode 100644 index 00000000000..8ff17092a7d --- /dev/null +++ b/tests/components/totalconnect/fixtures/device_1.json @@ -0,0 +1,12 @@ +{ + "DeviceID": 7654321, + "DeviceName": "test", + "DeviceClassID": 1, + "DeviceSerialNumber": "1234567890AB", + "DeviceFlags": "PromptForUserCode=0,PromptForInstallerCode=0,PromptForImportSecuritySettings=0,AllowUserSlotEditing=0,CalCapable=1,CanBeSentToPanel=1,CanArmNightStay=0,CanSupportMultiPartition=0,PartitionCount=0,MaxPartitionCount=4,OnBoardingSupport=0,PartitionAdded=0,DuplicateUserSyncStatus=0,PanelType=12,PanelVariant=1,BLEDisarmCapable=0,ArmHomeSupported=1,DuplicateUserCodeCheck=1,CanSupportRapid=0,IsKeypadSupported=0,WifiEnrollmentSupported=1,IsConnectedPanel=1,ArmNightInSceneSupported=1,BuiltInCameraSettingsSupported=0,ZWaveThermostatScheduleDisabled=0,MultipleAuthorityLevelSupported=1,VideoOnPanelSupported=1,EnableBLEMode=0,IsPanelWiFiResetSupported=0,IsCompetitorClearBypass=0,IsNotReadyStateSupported=0,isArmStatusWithoutExitDelayNotSupported=0,UserCodeLength=4,UserCodeLengthChanged=0,DoubleDisarmRequired=0,TMSCloudSupported=0,IsAVCEnabled=0", + "SecurityPanelTypeID": 12, + "DeviceSerialText": null, + "DeviceType": null, + "DeviceVariants": null, + "RestrictedPanel": 0 +} diff --git a/tests/components/totalconnect/fixtures/zones.json b/tests/components/totalconnect/fixtures/zones.json new file mode 100644 index 00000000000..2bb237f976b --- /dev/null +++ b/tests/components/totalconnect/fixtures/zones.json @@ -0,0 +1,658 @@ +[ + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "020000", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 2, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Security", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-12-11T09:00:13", + "ZoneTypeId": 1 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "030000", + "LoopNumber": 1, + "ResponseType": "4", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 2 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 3, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Fire", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-06-02T15:41:05", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "040000", + "LoopNumber": 1, + "ResponseType": "4", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 2 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 4, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Gas", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-12-11T09:00:13", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "050000", + "LoopNumber": 1, + "ResponseType": "4", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 2 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 5, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Unknown", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-06-02T15:40:59", + "ZoneTypeId": 99 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "060000", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 1, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 6, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Temperature", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 12 + }, + { + "PartitionId": 1, + "Batterylevel": 5, + "Signalstrength": 2, + "zoneAdditionalInfo": { + "SensorSerialNumber": "070000000000000A", + "LoopNumber": 2, + "ResponseType": "53", + "AlarmReportState": 0, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 15 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 7, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Doorbell Other", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 53 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "080000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 8, + "ZoneStatus": 1, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Office Side Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "090000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 9, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Office Back Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "100000", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 10, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Master Bedroom Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-06-02T15:40:57", + "ZoneTypeId": 1 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "120000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 12, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Dining Room Two Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "130000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 13, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Patio Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "140000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 1 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 14, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Living Room Window", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "150000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 1 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 15, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Living Room Two Window", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "160000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 16, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Apartment SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:42:29", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "170000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 17, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Upstairs Hallway SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:53:57", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "180000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 18, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Downstairs Hallway SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:47:10", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "190000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 19, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Kid Bedroom SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:49:07", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "200000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 20, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Guest Bedroom SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:50:20", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "210000", + "LoopNumber": 1, + "ResponseType": "14", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 6 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 21, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Apartment CarbonMonoxideDetecto", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:41:18", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "220000", + "LoopNumber": 1, + "ResponseType": "14", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 6 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 22, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Downstairs Hallway CarbonMonoxid", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:45:39", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "230000", + "LoopNumber": 1, + "ResponseType": "14", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 6 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 23, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Upstairs Hallway CarbonMonoxideD", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:52:37", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "240000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 24, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Master Bedroom SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": 5, + "Signalstrength": 3, + "zoneAdditionalInfo": { + "SensorSerialNumber": "250000000000000A", + "LoopNumber": 1, + "ResponseType": "23", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 15 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 25, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Garage Side Other", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-12-15T15:14:39", + "ZoneTypeId": 23 + }, + { + "PartitionId": 1, + "Batterylevel": 5, + "Signalstrength": 5, + "zoneAdditionalInfo": { + "SensorSerialNumber": "260000000000000A", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 26, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Front Door Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 1 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 800, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Master Bedroom Keypad", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 50 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1995, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 995 Fire", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1996, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 996 Medical", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 15 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1998, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 998 Other", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 6 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1999, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 999 Police", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 7 + } +] diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index 174ab96e8dc..a79fe3832cd 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_attributes[alarm_control_panel.test-entry] +# name: test_entities[alarm_control_panel.test-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -30,11 +30,11 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '123456', + 'unique_id': '1234567', 'unit_of_measurement': None, }) # --- -# name: test_attributes[alarm_control_panel.test-state] +# name: test_entities[alarm_control_panel.test-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, @@ -51,7 +51,7 @@ 'state': 'disarmed', }) # --- -# name: test_attributes[alarm_control_panel.test_partition_2-entry] +# name: test_entities[alarm_control_panel.test_partition_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -82,11 +82,11 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'partition', - 'unique_id': '123456_2', + 'unique_id': '1234567_2', 'unit_of_measurement': None, }) # --- -# name: test_attributes[alarm_control_panel.test_partition_2-state] +# name: test_entities[alarm_control_panel.test_partition_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, diff --git a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr index 75aaddf8572..55702b06acc 100644 --- a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr +++ b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr @@ -1,4 +1,940 @@ # serializer version: 1 +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_21_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Apartment CarbonMonoxideDetecto', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '21', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_21_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Apartment CarbonMonoxideDetecto Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '21', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_21_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Apartment CarbonMonoxideDetecto Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '21', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.apartment_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_16_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Apartment SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '16', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.apartment_smokedetector_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_16_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Apartment SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '16', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.apartment_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_16_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Apartment SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '16', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dining_room_two_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_12_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Dining Room Two Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '12', + }), + 'context': , + 'entity_id': 'binary_sensor.dining_room_two_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dining_room_two_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_12_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dining Room Two Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '12', + }), + 'context': , + 'entity_id': 'binary_sensor.dining_room_two_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dining_room_two_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_12_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Dining Room Two Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '12', + }), + 'context': , + 'entity_id': 'binary_sensor.dining_room_two_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.doorbell_other', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_7_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Doorbell Other', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '7', + }), + 'context': , + 'entity_id': 'binary_sensor.doorbell_other', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.doorbell_other_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_7_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Doorbell Other Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '7', + }), + 'context': , + 'entity_id': 'binary_sensor.doorbell_other_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.doorbell_other_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_7_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Doorbell Other Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '7', + }), + 'context': , + 'entity_id': 'binary_sensor.doorbell_other_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_22_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Downstairs Hallway CarbonMonoxid', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '22', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_22_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Downstairs Hallway CarbonMonoxid Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '22', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_22_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Downstairs Hallway CarbonMonoxid Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '22', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_18_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Downstairs Hallway SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '18', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_18_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Downstairs Hallway SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '18', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_18_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Downstairs Hallway SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '18', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_registry[binary_sensor.fire-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -30,7 +966,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_2_zone', + 'unique_id': '1234567_3_zone', 'unit_of_measurement': None, }) # --- @@ -39,16 +975,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', 'friendly_name': 'Fire', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '2', + 'zone_id': '3', }), 'context': , 'entity_id': 'binary_sensor.fire', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.fire_battery-entry] @@ -82,7 +1018,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_2_low_battery', + 'unique_id': '1234567_3_low_battery', 'unit_of_measurement': None, }) # --- @@ -91,9 +1027,9 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Fire Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '2', + 'zone_id': '3', }), 'context': , 'entity_id': 'binary_sensor.fire_battery', @@ -134,7 +1070,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_2_tamper', + 'unique_id': '1234567_3_tamper', 'unit_of_measurement': None, }) # --- @@ -143,16 +1079,328 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Fire Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '2', + 'zone_id': '3', }), 'context': , 'entity_id': 'binary_sensor.fire_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_26_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Front Door Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '26', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.front_door_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_26_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '26', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.front_door_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_26_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Front Door Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '26', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_side_other', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_25_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Garage Side Other', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '25', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_side_other', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garage_side_other_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_25_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Side Other Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '25', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_side_other_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garage_side_other_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_25_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Garage Side Other Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '25', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_side_other_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.gas-entry] @@ -178,7 +1426,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -186,25 +1434,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_3_zone', + 'unique_id': '1234567_4_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.gas-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'gas', + 'device_class': 'smoke', 'friendly_name': 'Gas', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '3', + 'zone_id': '4', }), 'context': , 'entity_id': 'binary_sensor.gas', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.gas_battery-entry] @@ -238,7 +1486,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_3_low_battery', + 'unique_id': '1234567_4_low_battery', 'unit_of_measurement': None, }) # --- @@ -247,16 +1495,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Gas Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '3', + 'zone_id': '4', }), 'context': , 'entity_id': 'binary_sensor.gas_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.gas_tamper-entry] @@ -290,7 +1538,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_3_tamper', + 'unique_id': '1234567_4_tamper', 'unit_of_measurement': None, }) # --- @@ -299,9 +1547,9 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Gas Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '3', + 'zone_id': '4', }), 'context': , 'entity_id': 'binary_sensor.gas_tamper', @@ -311,7 +1559,7 @@ 'state': 'on', }) # --- -# name: test_entity_registry[binary_sensor.medical-entry] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -324,7 +1572,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.medical', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -334,7 +1582,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -342,80 +1590,28 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_5_zone', + 'unique_id': '1234567_20_zone', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[binary_sensor.medical-state] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'safety', - 'friendly_name': 'Medical', - 'location_id': 123456, + 'device_class': 'smoke', + 'friendly_name': 'Guest Bedroom SmokeDetector', + 'location_id': 1234567, 'partition': '1', - 'zone_id': '5', + 'zone_id': '20', }), 'context': , - 'entity_id': 'binary_sensor.medical', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- -# name: test_entity_registry[binary_sensor.motion-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.motion', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'totalconnect', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456_4_zone', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity_registry[binary_sensor.motion-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'motion', - 'friendly_name': 'Motion', - 'location_id': 123456, - 'partition': '1', - 'zone_id': '4', - }), - 'context': , - 'entity_id': 'binary_sensor.motion', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_entity_registry[binary_sensor.motion_battery-entry] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -428,7 +1624,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.motion_battery', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -446,28 +1642,28 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_4_low_battery', + 'unique_id': '1234567_20_low_battery', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[binary_sensor.motion_battery-state] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Motion Battery', - 'location_id': 123456, + 'friendly_name': 'Guest Bedroom SmokeDetector Battery', + 'location_id': 1234567, 'partition': '1', - 'zone_id': '4', + 'zone_id': '20', }), 'context': , - 'entity_id': 'binary_sensor.motion_battery', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- -# name: test_entity_registry[binary_sensor.motion_tamper-entry] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_tamper-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -480,7 +1676,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.motion_tamper', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_tamper', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -498,25 +1694,1429 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_4_tamper', + 'unique_id': '1234567_20_tamper', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[binary_sensor.motion_tamper-state] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_tamper-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', - 'friendly_name': 'Motion Tamper', - 'location_id': 123456, + 'friendly_name': 'Guest Bedroom SmokeDetector Tamper', + 'location_id': 1234567, 'partition': '1', - 'zone_id': '4', + 'zone_id': '20', }), 'context': , - 'entity_id': 'binary_sensor.motion_tamper', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_19_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Kid Bedroom SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '19', + }), + 'context': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_19_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kid Bedroom SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '19', + }), + 'context': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_19_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Kid Bedroom SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '19', + }), + 'context': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_two_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_15_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Living Room Two Window', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '15', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_two_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.living_room_two_window_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_15_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Two Window Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '15', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_two_window_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.living_room_two_window_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_15_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Living Room Two Window Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '15', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_two_window_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_14_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Living Room Window', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '14', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.living_room_window_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_14_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Window Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '14', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_window_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.living_room_window_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_14_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Living Room Window Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '14', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_window_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_bedroom_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_10_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Master Bedroom Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '10', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.master_bedroom_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_10_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bedroom Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '10', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.master_bedroom_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_10_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Master Bedroom Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '10', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_bedroom_keypad', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_800_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Master Bedroom Keypad', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '800', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_keypad', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.master_bedroom_keypad_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_800_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bedroom Keypad Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '800', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_keypad_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.master_bedroom_keypad_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_800_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Master Bedroom Keypad Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '800', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_keypad_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_bedroom_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_24_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Master Bedroom SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '24', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_24_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bedroom SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '24', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_24_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Master Bedroom SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '24', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.office_back_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_9_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Office Back Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '9', + }), + 'context': , + 'entity_id': 'binary_sensor.office_back_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.office_back_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_9_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Office Back Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '9', + }), + 'context': , + 'entity_id': 'binary_sensor.office_back_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.office_back_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_9_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Office Back Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '9', + }), + 'context': , + 'entity_id': 'binary_sensor.office_back_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.office_side_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_8_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Office Side Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '8', + }), + 'context': , + 'entity_id': 'binary_sensor.office_side_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.office_side_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_8_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Office Side Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '8', + }), + 'context': , + 'entity_id': 'binary_sensor.office_side_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.office_side_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_8_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Office Side Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '8', + }), + 'context': , + 'entity_id': 'binary_sensor.office_side_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.patio_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_13_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Patio Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '13', + }), + 'context': , + 'entity_id': 'binary_sensor.patio_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.patio_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_13_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Patio Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '13', + }), + 'context': , + 'entity_id': 'binary_sensor.patio_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.patio_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_13_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Patio Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '13', + }), + 'context': , + 'entity_id': 'binary_sensor.patio_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.security-entry] @@ -542,7 +3142,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -550,18 +3150,18 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_1_zone', + 'unique_id': '1234567_2_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.security-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', + 'device_class': 'smoke', 'friendly_name': 'Security', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '1', + 'zone_id': '2', }), 'context': , 'entity_id': 'binary_sensor.security', @@ -602,7 +3202,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_1_low_battery', + 'unique_id': '1234567_2_low_battery', 'unit_of_measurement': None, }) # --- @@ -611,16 +3211,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Security Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '1', + 'zone_id': '2', }), 'context': , 'entity_id': 'binary_sensor.security_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.security_tamper-entry] @@ -654,7 +3254,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_1_tamper', + 'unique_id': '1234567_2_tamper', 'unit_of_measurement': None, }) # --- @@ -663,16 +3263,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Security Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '1', + 'zone_id': '2', }), 'context': , 'entity_id': 'binary_sensor.security_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.temperature-entry] @@ -698,7 +3298,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -706,25 +3306,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_7_zone', + 'unique_id': '1234567_6_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'problem', + 'device_class': 'smoke', 'friendly_name': 'Temperature', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': 7, + 'zone_id': '6', }), 'context': , 'entity_id': 'binary_sensor.temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.temperature_battery-entry] @@ -758,7 +3358,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_7_low_battery', + 'unique_id': '1234567_6_low_battery', 'unit_of_measurement': None, }) # --- @@ -767,16 +3367,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Temperature Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': 7, + 'zone_id': '6', }), 'context': , 'entity_id': 'binary_sensor.temperature_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.temperature_tamper-entry] @@ -810,7 +3410,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_7_tamper', + 'unique_id': '1234567_6_tamper', 'unit_of_measurement': None, }) # --- @@ -819,16 +3419,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Temperature Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': 7, + 'zone_id': '6', }), 'context': , 'entity_id': 'binary_sensor.temperature_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.test_battery-entry] @@ -862,7 +3462,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_low_battery', + 'unique_id': '1234567_low_battery', 'unit_of_measurement': None, }) # --- @@ -871,7 +3471,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'test Battery', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_battery', @@ -912,7 +3512,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_carbon_monoxide', + 'unique_id': '1234567_carbon_monoxide', 'unit_of_measurement': None, }) # --- @@ -921,7 +3521,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'carbon_monoxide', 'friendly_name': 'test Carbon monoxide', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_carbon_monoxide', @@ -962,7 +3562,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'police', - 'unique_id': '123456_police', + 'unique_id': '1234567_police', 'unit_of_measurement': None, }) # --- @@ -970,7 +3570,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'test Police emergency', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_police_emergency', @@ -1011,7 +3611,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_power', + 'unique_id': '1234567_power', 'unit_of_measurement': None, }) # --- @@ -1020,7 +3620,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'test Power', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_power', @@ -1061,7 +3661,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_smoke', + 'unique_id': '1234567_smoke', 'unit_of_measurement': None, }) # --- @@ -1070,7 +3670,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', 'friendly_name': 'test Smoke', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_smoke', @@ -1111,7 +3711,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_tamper', + 'unique_id': '1234567_tamper', 'unit_of_measurement': None, }) # --- @@ -1120,7 +3720,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'test Tamper', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_tamper', @@ -1153,7 +3753,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -1161,25 +3761,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_6_zone', + 'unique_id': '1234567_5_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.unknown-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', + 'device_class': 'smoke', 'friendly_name': 'Unknown', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '6', + 'zone_id': '5', }), 'context': , 'entity_id': 'binary_sensor.unknown', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.unknown_battery-entry] @@ -1213,7 +3813,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_6_low_battery', + 'unique_id': '1234567_5_low_battery', 'unit_of_measurement': None, }) # --- @@ -1222,16 +3822,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Unknown Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '6', + 'zone_id': '5', }), 'context': , 'entity_id': 'binary_sensor.unknown_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.unknown_tamper-entry] @@ -1265,7 +3865,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_6_tamper', + 'unique_id': '1234567_5_tamper', 'unit_of_measurement': None, }) # --- @@ -1274,15 +3874,951 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Unknown Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '6', + 'zone_id': '5', }), 'context': , 'entity_id': 'binary_sensor.unknown_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_23_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Upstairs Hallway CarbonMonoxideD', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '23', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_23_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs Hallway CarbonMonoxideD Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '23', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_23_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Upstairs Hallway CarbonMonoxideD Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '23', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_17_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Upstairs Hallway SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '17', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_17_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs Hallway SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '17', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_17_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Upstairs Hallway SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '17', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zone_995_fire', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1995_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 995 Fire', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1995', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_995_fire', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_995_fire_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1995_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 995 Fire Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1995', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_995_fire_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_995_fire_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1995_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 995 Fire Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1995', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_995_fire_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zone_996_medical', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1996_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 996 Medical', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1996', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_996_medical', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_996_medical_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1996_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 996 Medical Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1996', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_996_medical_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_996_medical_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1996_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 996 Medical Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1996', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_996_medical_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zone_998_other', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1998_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 998 Other', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1998', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_998_other', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_998_other_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1998_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 998 Other Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1998', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_998_other_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_998_other_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1998_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 998 Other Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1998', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_998_other_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zone_999_police', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1999_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 999 Police', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1999', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_999_police', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_999_police_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1999_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 999 Police Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1999', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_999_police_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zone_999_police_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1999_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 999 Police Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1999', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_999_police_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', }) # --- diff --git a/tests/components/totalconnect/snapshots/test_button.ambr b/tests/components/totalconnect/snapshots/test_button.ambr index 4367b035cc8..db90af349cb 100644 --- a/tests/components/totalconnect/snapshots/test_button.ambr +++ b/tests/components/totalconnect/snapshots/test_button.ambr @@ -1,4 +1,100 @@ # serializer version: 1 +# name: test_entity_registry[button.dining_room_two_door_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dining_room_two_door_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_12_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.dining_room_two_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dining Room Two Door Bypass', + }), + 'context': , + 'entity_id': 'button.dining_room_two_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.doorbell_other_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.doorbell_other_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_7_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.doorbell_other_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Doorbell Other Bypass', + }), + 'context': , + 'entity_id': 'button.doorbell_other_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entity_registry[button.fire_bypass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -30,7 +126,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_2_bypass', + 'unique_id': '1234567_3_bypass', 'unit_of_measurement': None, }) # --- @@ -47,6 +143,102 @@ 'state': 'unknown', }) # --- +# name: test_entity_registry[button.front_door_door_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.front_door_door_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_26_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.front_door_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Front Door Door Bypass', + }), + 'context': , + 'entity_id': 'button.front_door_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.garage_side_other_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.garage_side_other_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_25_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.garage_side_other_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Side Other Bypass', + }), + 'context': , + 'entity_id': 'button.garage_side_other_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entity_registry[button.gas_bypass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -78,7 +270,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_3_bypass', + 'unique_id': '1234567_4_bypass', 'unit_of_measurement': None, }) # --- @@ -95,7 +287,7 @@ 'state': 'unknown', }) # --- -# name: test_entity_registry[button.motion_bypass-entry] +# name: test_entity_registry[button.living_room_two_window_bypass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,7 +300,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.motion_bypass', + 'entity_id': 'button.living_room_two_window_bypass', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -126,17 +318,257 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_4_bypass', + 'unique_id': '1234567_15_bypass', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[button.motion_bypass-state] +# name: test_entity_registry[button.living_room_two_window_bypass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Motion Bypass', + 'friendly_name': 'Living Room Two Window Bypass', }), 'context': , - 'entity_id': 'button.motion_bypass', + 'entity_id': 'button.living_room_two_window_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.living_room_window_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_window_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_14_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.living_room_window_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Window Bypass', + }), + 'context': , + 'entity_id': 'button.living_room_window_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.master_bedroom_door_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_bedroom_door_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_10_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.master_bedroom_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Master Bedroom Door Bypass', + }), + 'context': , + 'entity_id': 'button.master_bedroom_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.office_back_door_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.office_back_door_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_9_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.office_back_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Back Door Bypass', + }), + 'context': , + 'entity_id': 'button.office_back_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.office_side_door_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.office_side_door_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_8_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.office_side_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Side Door Bypass', + }), + 'context': , + 'entity_id': 'button.office_side_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.patio_door_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.patio_door_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_13_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.patio_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Patio Door Bypass', + }), + 'context': , + 'entity_id': 'button.patio_door_bypass', 'last_changed': , 'last_reported': , 'last_updated': , @@ -174,7 +606,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_1_bypass', + 'unique_id': '1234567_2_bypass', 'unit_of_measurement': None, }) # --- @@ -191,6 +623,54 @@ 'state': 'unknown', }) # --- +# name: test_entity_registry[button.temperature_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.temperature_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_6_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.temperature_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Temperature Bypass', + }), + 'context': , + 'entity_id': 'button.temperature_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entity_registry[button.test_bypass_all-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -222,7 +702,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass_all', - 'unique_id': '123456_bypass_all', + 'unique_id': '1234567_bypass_all', 'unit_of_measurement': None, }) # --- @@ -270,7 +750,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clear_bypass', - 'unique_id': '123456_clear_bypass', + 'unique_id': '1234567_clear_bypass', 'unit_of_measurement': None, }) # --- @@ -287,3 +767,51 @@ 'state': 'unknown', }) # --- +# name: test_entity_registry[button.unknown_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.unknown_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_5_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.unknown_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Unknown Bypass', + }), + 'context': , + 'entity_id': 'button.unknown_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/totalconnect/snapshots/test_diagnostics.ambr b/tests/components/totalconnect/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..026afca0920 --- /dev/null +++ b/tests/components/totalconnect/snapshots/test_diagnostics.ambr @@ -0,0 +1,619 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'client': dict({ + 'auto_bypass_low_battery': False, + 'invalid_credentials': False, + 'module_flags': dict({ + }), + 'retry_delay': 0, + }), + 'locations': list([ + dict({ + 'ac_loss': False, + 'arming_state': dict({ + '__type': "", + 'repr': '', + }), + 'auto_bypass_low_battery': False, + 'cover_tampered': False, + 'devices': list([ + dict({ + 'class_id': 1, + 'device_id': 7654321, + 'flags': dict({ + 'AllowUserSlotEditing': '0', + 'ArmHomeSupported': '1', + 'ArmNightInSceneSupported': '1', + 'BLEDisarmCapable': '0', + 'BuiltInCameraSettingsSupported': '0', + 'CalCapable': '1', + 'CanArmNightStay': '0', + 'CanBeSentToPanel': '1', + 'CanSupportMultiPartition': '0', + 'CanSupportRapid': '0', + 'DoubleDisarmRequired': '0', + 'DuplicateUserCodeCheck': '1', + 'DuplicateUserSyncStatus': '0', + 'EnableBLEMode': '0', + 'IsAVCEnabled': '0', + 'IsCompetitorClearBypass': '0', + 'IsConnectedPanel': '1', + 'IsKeypadSupported': '0', + 'IsNotReadyStateSupported': '0', + 'IsPanelWiFiResetSupported': '0', + 'MaxPartitionCount': '4', + 'MultipleAuthorityLevelSupported': '1', + 'OnBoardingSupport': '0', + 'PanelType': '12', + 'PanelVariant': '1', + 'PartitionAdded': '0', + 'PartitionCount': '0', + 'PromptForImportSecuritySettings': '0', + 'PromptForInstallerCode': '0', + 'PromptForUserCode': '0', + 'TMSCloudSupported': '0', + 'UserCodeLength': '4', + 'UserCodeLengthChanged': '0', + 'VideoOnPanelSupported': '1', + 'WifiEnrollmentSupported': '1', + 'ZWaveThermostatScheduleDisabled': '0', + 'isArmStatusWithoutExitDelayNotSupported': '0', + }), + 'name': 'test', + 'security_panel_type_id': 12, + 'serial_number': '**REDACTED**', + 'serial_text': None, + }), + ]), + 'location_id': 1234567, + 'low_battery': False, + 'module_flags': dict({ + 'can_bypass_zones': True, + 'can_clear_bypass': True, + 'can_set_usercodes': True, + }), + 'name': 'Test Location', + 'partitions': list([ + dict({ + 'arming_state': dict({ + '__type': "", + 'repr': '', + }), + 'exit_delay_timer': 0, + 'is_common_enabled': False, + 'is_fire_enabled': False, + 'is_locked': False, + 'is_new_partition': False, + 'is_night_stay_enabled': 0, + 'is_stay_armed': False, + 'name': 'Test1', + 'partition_id': 1, + }), + dict({ + 'arming_state': dict({ + '__type': "", + 'repr': '', + }), + 'exit_delay_timer': 0, + 'is_common_enabled': False, + 'is_fire_enabled': False, + 'is_locked': False, + 'is_new_partition': False, + 'is_night_stay_enabled': 0, + 'is_stay_armed': False, + 'name': 'Test2', + 'partition_id': 2, + }), + ]), + 'security_device_id': 7654321, + 'zones': list([ + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Security', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 2, + 'zone_type_id': 1, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Fire', + 'device_type': 2, + 'loop_number': 1, + 'partition': 1, + 'response_type': '4', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 3, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Gas', + 'device_type': 2, + 'loop_number': 1, + 'partition': 1, + 'response_type': '4', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 4, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Unknown', + 'device_type': 2, + 'loop_number': 1, + 'partition': 1, + 'response_type': '4', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 5, + 'zone_type_id': 99, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Temperature', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 1, + 'zone_id': 6, + 'zone_type_id': 12, + }), + dict({ + 'alarm_report_state': 0, + 'battery_level': 5, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Doorbell Other', + 'device_type': 15, + 'loop_number': 2, + 'partition': 1, + 'response_type': '53', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': 2, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 7, + 'zone_type_id': 53, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Office Side Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 1, + 'supervision_type': 0, + 'zone_id': 8, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Office Back Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 9, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Master Bedroom Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 10, + 'zone_type_id': 1, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Dining Room Two Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 12, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Patio Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 13, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Living Room Window', + 'device_type': 1, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 14, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Living Room Two Window', + 'device_type': 1, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 15, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Apartment SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 16, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Upstairs Hallway SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 17, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Downstairs Hallway SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 18, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Kid Bedroom SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 19, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Guest Bedroom SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 20, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Apartment CarbonMonoxideDetecto', + 'device_type': 6, + 'loop_number': 1, + 'partition': 1, + 'response_type': '14', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 21, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Downstairs Hallway CarbonMonoxid', + 'device_type': 6, + 'loop_number': 1, + 'partition': 1, + 'response_type': '14', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 22, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Upstairs Hallway CarbonMonoxideD', + 'device_type': 6, + 'loop_number': 1, + 'partition': 1, + 'response_type': '14', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 23, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Master Bedroom SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 24, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': 5, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Garage Side Other', + 'device_type': 15, + 'loop_number': 1, + 'partition': 1, + 'response_type': '23', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': 3, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 25, + 'zone_type_id': 23, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': 5, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Front Door Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': 5, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 26, + 'zone_type_id': 1, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Master Bedroom Keypad', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 800, + 'zone_type_id': 50, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 995 Fire', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1995, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 996 Medical', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1996, + 'zone_type_id': 15, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 998 Other', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1998, + 'zone_type_id': 6, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 999 Police', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1999, + 'zone_type_id': 7, + }), + ]), + }), + ]), + 'user': dict({ + 'config_admin': True, + 'features': dict({ + 'can_bypass_zones': True, + 'can_clear_bypass': True, + 'can_set_usercodes': True, + }), + 'master': True, + 'security_problem': False, + 'user_admin': True, + }), + }) +# --- diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 6f7d8163362..040cdf5d9ed 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -1,19 +1,16 @@ """Tests for the TotalConnect alarm control panel device.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from total_connect_client.exceptions import ( - AuthenticationError, - ServiceUnavailable, - TotalConnectError, -) +from total_connect_client import ArmingState, ArmType +from total_connect_client.exceptions import BadResultCodeError, UsercodeInvalid from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_DOMAIN, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelState, ) from homeassistant.components.totalconnect.alarm_control_panel import ( @@ -21,593 +18,375 @@ from homeassistant.components.totalconnect.alarm_control_panel import ( SERVICE_ALARM_ARM_HOME_INSTANT, ) from homeassistant.components.totalconnect.const import DOMAIN -from homeassistant.components.totalconnect.coordinator import SCAN_INTERVAL -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, - STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import async_update_entity -from .common import ( - LOCATION_ID, - RESPONSE_ARM_FAILURE, - RESPONSE_ARM_SUCCESS, - RESPONSE_ARMED_AWAY, - RESPONSE_ARMED_CUSTOM, - RESPONSE_ARMED_NIGHT, - RESPONSE_ARMED_STAY, - RESPONSE_ARMING, - RESPONSE_DISARM_FAILURE, - RESPONSE_DISARM_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMING, - RESPONSE_SUCCESS, - RESPONSE_UNKNOWN, - RESPONSE_USER_CODE_INVALID, - TOTALCONNECT_REQUEST, - USERCODES, - setup_platform, -) +from . import setup_integration +from .const import CODE -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "alarm_control_panel.test" ENTITY_ID_2 = "alarm_control_panel.test_partition_2" -CODE = "-1" DATA = {ATTR_ENTITY_ID: ENTITY_ID} DELAY = timedelta(seconds=10) +ARMING_HELPER = "homeassistant.components.totalconnect.alarm_control_panel.ArmingHelper" -async def test_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test the alarm control panel attributes are correct.""" - entry = await setup_platform(hass, ALARM_DOMAIN) with patch( - "homeassistant.components.totalconnect.TotalConnectClient.request", - return_value=RESPONSE_DISARMED, - ) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - mock_request.assert_called_once() + "homeassistant.components.totalconnect.PLATFORMS", + [Platform.ALARM_CONTROL_PANEL], + ): + await setup_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) - assert mock_request.call_count == 1 + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_arm_home_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [False, True]) +@pytest.mark.parametrize( + ("service", "arm_type"), + [ + (SERVICE_ALARM_ARM_HOME, ArmType.STAY), + (SERVICE_ALARM_ARM_NIGHT, ArmType.STAY_NIGHT), + (SERVICE_ALARM_ARM_AWAY, ArmType.AWAY), + ], +) +async def test_arming( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + arm_type: ArmType, ) -> None: - """Test arm home method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_STAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming method success.""" + await setup_integration(hass, mock_config_entry) - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True - ) - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_HOME - # second partition should not be armed - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED + mock_partition.arming_state = ArmingState.ARMING + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: CODE}, + blocking=True, + ) + assert mock_partition.arm.call_args[1] == {"arm_type": arm_type, "usercode": ""} + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING -async def test_arm_home_failure(hass: HomeAssistant) -> None: - """Test arm home method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm home test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # config entry usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm home" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arm_home_instant_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [True]) +@pytest.mark.parametrize( + ("service", "arm_type"), + [ + (SERVICE_ALARM_ARM_HOME, ArmType.STAY), + (SERVICE_ALARM_ARM_NIGHT, ArmType.STAY_NIGHT), + (SERVICE_ALARM_ARM_AWAY, ArmType.AWAY), + ], +) +async def test_arming_invalid_usercode( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + arm_type: ArmType, ) -> None: - """Test arm home instant method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_STAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming method with invalid usercode.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(ServiceValidationError, match="Incorrect code entered"): await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: "invalid_code"}, + blocking=True, ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_HOME + assert mock_partition.arm.call_count == 0 + assert mock_location.get_panel_meta_data.call_count == 1 -async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: - """Test arm home instant method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm home instant test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert str(err.value) == "Usercode is invalid, did not arm home instant" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arm_away_instant_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [False, True]) +async def test_disarming( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test arm home instant method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test disarming method success.""" + await setup_integration(hass, mock_config_entry) - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True - ) - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY + mock_partition.arming_state = ArmingState.ARMING + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: CODE}, + blocking=True, + ) + assert mock_partition.disarm.call_args[1] == {"usercode": ""} + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING -async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: - """Test arm home instant method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm away instant test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm away instant" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arm_away_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [True]) +async def test_disarming_invalid_usercode( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test arm away method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test disarming method with invalid usercode.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(ServiceValidationError, match="Incorrect code entered"): await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: "invalid_code"}, + blocking=True, ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY + assert mock_partition.disarm.call_count == 0 + assert mock_location.get_panel_meta_data.call_count == 1 -async def test_arm_away_failure(hass: HomeAssistant) -> None: - """Test arm away method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm away test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm away" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_disarm_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("service", "arm_type"), + [ + (SERVICE_ALARM_ARM_HOME_INSTANT, ArmType.STAY_INSTANT), + (SERVICE_ALARM_ARM_AWAY_INSTANT, ArmType.AWAY_INSTANT), + ], +) +async def test_instant_arming( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + arm_type: ArmType, ) -> None: - """Test disarm method success.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_DISARM_SUCCESS, RESPONSE_DISARMED] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 + """Test instant arming method success.""" + await setup_integration(hass, mock_config_entry) - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED + mock_partition.arming_state = ArmingState.ARMING + + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_partition.arm.call_args[1] == {"arm_type": arm_type, "usercode": ""} + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING -async def test_disarm_failure(hass: HomeAssistant) -> None: - """Test disarm method failure.""" - responses = [ - RESPONSE_ARMED_AWAY, - RESPONSE_DISARM_FAILURE, - RESPONSE_USER_CODE_INVALID, - ] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to disarm test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not disarm" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_disarm_code_required( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("exception", "suffix", "flows"), + [(UsercodeInvalid, "invalid_code", 1), (BadResultCodeError, "failed", 0)], +) +@pytest.mark.parametrize( + ("service", "prefix"), + [ + (SERVICE_ALARM_ARM_HOME, "arm_home"), + (SERVICE_ALARM_ARM_NIGHT, "arm_night"), + (SERVICE_ALARM_ARM_AWAY, "arm_away"), + ], +) +async def test_arming_exceptions( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + prefix: str, + exception: Exception, + suffix: str, + flows: int, ) -> None: - """Test disarm with code.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_DISARM_SUCCESS, RESPONSE_DISARMED] - await setup_platform(hass, ALARM_DOMAIN, code_required=True) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 + """Test arming method exceptions.""" + await setup_integration(hass, mock_config_entry) - # runtime user entered code is bad - DATA_WITH_CODE = DATA.copy() - DATA_WITH_CODE["code"] = "666" - with pytest.raises(ServiceValidationError, match="Incorrect code entered"): - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA_WITH_CODE, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - # code check means the call to total_connect never happens - assert mock_request.call_count == 1 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 - # runtime user entered code that is in config - DATA_WITH_CODE["code"] = USERCODES[LOCATION_ID] + mock_partition.arm.side_effect = exception + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(HomeAssistantError) as exc: await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA_WITH_CODE, blocking=True + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: CODE}, + blocking=True, ) - await hass.async_block_till_done() - assert mock_request.call_count == 2 + assert mock_partition.arm.call_count == 1 - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED + assert exc.value.translation_key == f"{prefix}_{suffix}" + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == flows -async def test_arm_night_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("exception", "suffix", "flows"), + [(UsercodeInvalid, "invalid_code", 1), (BadResultCodeError, "failed", 0)], +) +@pytest.mark.parametrize( + ("service", "prefix"), + [ + (SERVICE_ALARM_ARM_HOME_INSTANT, "arm_home_instant"), + (SERVICE_ALARM_ARM_AWAY_INSTANT, "arm_away_instant"), + ], +) +async def test_instant_arming_exceptions( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + prefix: str, + exception: Exception, + suffix: str, + flows: int, ) -> None: - """Test arm night method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_NIGHT] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming method exceptions.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + mock_partition.arm.side_effect = exception + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(HomeAssistantError) as exc: await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True + DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) - assert mock_request.call_count == 2 + assert mock_partition.arm.call_count == 1 - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_NIGHT + assert exc.value.translation_key == f"{prefix}_{suffix}" + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == flows -async def test_arm_night_failure(hass: HomeAssistant) -> None: - """Test arm night method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm night test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm night" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Test arming.""" - responses = [RESPONSE_DISARMED, RESPONSE_SUCCESS, RESPONSE_ARMING] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True - ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMING - - -async def test_disarming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Test disarming.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_SUCCESS, RESPONSE_DISARMING] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 - - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMING - - -async def test_armed_custom(hass: HomeAssistant) -> None: - """Test armed custom.""" - responses = [RESPONSE_ARMED_CUSTOM] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert ( - hass.states.get(ENTITY_ID).state - == AlarmControlPanelState.ARMED_CUSTOM_BYPASS - ) - assert mock_request.call_count == 1 - - -async def test_unknown(hass: HomeAssistant) -> None: - """Test unknown arm status.""" - responses = [RESPONSE_UNKNOWN] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 1 - - -async def test_other_update_failures( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("arming_state", "state"), + [ + (ArmingState.DISARMED, AlarmControlPanelState.DISARMED), + (ArmingState.DISARMED_BYPASS, AlarmControlPanelState.DISARMED), + (ArmingState.DISARMED_ZONE_FAULTED, AlarmControlPanelState.DISARMED), + (ArmingState.ARMED_STAY_NIGHT, AlarmControlPanelState.ARMED_NIGHT), + (ArmingState.ARMED_STAY_NIGHT_BYPASS_PROA7, AlarmControlPanelState.ARMED_NIGHT), + ( + ArmingState.ARMED_STAY_NIGHT_INSTANT_PROA7, + AlarmControlPanelState.ARMED_NIGHT, + ), + ( + ArmingState.ARMED_STAY_NIGHT_INSTANT_BYPASS_PROA7, + AlarmControlPanelState.ARMED_NIGHT, + ), + (ArmingState.ARMED_STAY, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_PROA7, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_BYPASS, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_BYPASS_PROA7, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT_PROA7, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_HOME), + ( + ArmingState.ARMED_STAY_INSTANT_BYPASS_PROA7, + AlarmControlPanelState.ARMED_HOME, + ), + (ArmingState.ARMED_STAY_OTHER, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_AWAY, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_AWAY_BYPASS, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_AWAY_INSTANT, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_AWAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_CUSTOM_BYPASS, AlarmControlPanelState.ARMED_CUSTOM_BYPASS), + (ArmingState.ARMING, AlarmControlPanelState.ARMING), + (ArmingState.DISARMING, AlarmControlPanelState.DISARMING), + (ArmingState.ALARMING, AlarmControlPanelState.TRIGGERED), + (ArmingState.ALARMING_FIRE_SMOKE, AlarmControlPanelState.TRIGGERED), + (ArmingState.ALARMING_CARBON_MONOXIDE, AlarmControlPanelState.TRIGGERED), + (ArmingState.ALARMING_CARBON_MONOXIDE_PROA7, AlarmControlPanelState.TRIGGERED), + ], +) +async def test_arming_state( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + arming_state: ArmingState, + state: AlarmControlPanelState, + freezer: FrozenDateTimeFactory, ) -> None: - """Test other failures seen during updates.""" - responses = [ - RESPONSE_DISARMED, - ServiceUnavailable, - RESPONSE_DISARMED, - TotalConnectError, - RESPONSE_DISARMED, - ValueError, - ] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - # first things work as planned - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming state transitions.""" + await setup_integration(hass, mock_config_entry) - # then an error: ServiceUnavailable --> UpdateFailed - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - # works again - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 3 + mock_partition.arming_state = arming_state - # then an error: TotalConnectError --> UpdateFailed - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 4 + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - # works again - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 5 - - # unknown TotalConnect status via ValueError - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 6 - - -async def test_authentication_error(hass: HomeAssistant) -> None: - """Test other failures seen during updates.""" - entry = await setup_platform(hass, ALARM_DOMAIN) - - with patch(TOTALCONNECT_REQUEST, side_effect=AuthenticationError): - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.LOADED - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" - assert flow.get("handler") == DOMAIN - - assert "context" in flow - assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert hass.states.get(entity_id).state == state diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py index 8910487ea58..3083dd8c629 100644 --- a/tests/components/totalconnect/test_binary_sensor.py +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -1,91 +1,29 @@ """Tests for the TotalConnect binary sensor.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR, - BinarySensorDeviceClass, -) -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import RESPONSE_DISARMED, ZONE_NORMAL, setup_platform +from . import setup_integration -from tests.common import snapshot_platform - -ZONE_ENTITY_ID = "binary_sensor.security" -ZONE_LOW_BATTERY_ID = "binary_sensor.security_battery" -ZONE_TAMPER_ID = "binary_sensor.security_tamper" -PANEL_BATTERY_ID = "binary_sensor.test_battery" -PANEL_TAMPER_ID = "binary_sensor.test_tamper" -PANEL_POWER_ID = "binary_sensor.test_power" +from tests.common import MockConfigEntry, snapshot_platform async def test_entity_registry( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: - """Test the binary sensor is registered in entity registry.""" - entry = await setup_platform(hass, BINARY_SENSOR) - - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) - - -async def test_state_and_attributes(hass: HomeAssistant) -> None: - """Test the binary sensor attributes are correct.""" - + """Test the alarm control panel attributes are correct.""" with patch( - "homeassistant.components.totalconnect.TotalConnectClient.request", - return_value=RESPONSE_DISARMED, + "homeassistant.components.totalconnect.PLATFORMS", [Platform.BINARY_SENSOR] ): - await setup_platform(hass, BINARY_SENSOR) + await setup_integration(hass, mock_config_entry) - state = hass.states.get(ZONE_ENTITY_ID) - assert state.state == STATE_ON - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == ZONE_NORMAL["ZoneDescription"] - ) - assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR - - state = hass.states.get(f"{ZONE_ENTITY_ID}_battery") - assert state.state == STATE_OFF - state = hass.states.get(f"{ZONE_ENTITY_ID}_tamper") - assert state.state == STATE_OFF - - # Zone 2 is fire with low battery - state = hass.states.get("binary_sensor.fire") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.SMOKE - state = hass.states.get("binary_sensor.fire_battery") - assert state.state == STATE_ON - state = hass.states.get("binary_sensor.fire_tamper") - assert state.state == STATE_OFF - - # Zone 3 is gas with tamper - state = hass.states.get("binary_sensor.gas") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.GAS - state = hass.states.get("binary_sensor.gas_battery") - assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.gas_tamper") - assert state.state == STATE_ON - - # Zone 6 is unknown type, assume it is a security (door) sensor - state = hass.states.get("binary_sensor.unknown") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR - state = hass.states.get("binary_sensor.unknown_battery") - assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.unknown_tamper") - assert state.state == STATE_OFF - - # Zone 7 is temperature - state = hass.states.get("binary_sensor.temperature") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.PROBLEM - state = hass.states.get("binary_sensor.temperature_battery") - assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.temperature_tamper") - assert state.state == STATE_OFF + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py index 092b058e693..9492d815152 100644 --- a/tests/components/totalconnect/test_button.py +++ b/tests/components/totalconnect/test_button.py @@ -1,84 +1,64 @@ """Tests for the TotalConnect buttons.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -import pytest from syrupy.assertion import SnapshotAssertion -from total_connect_client.exceptions import FailedToBypassZone -from homeassistant.components.button import DOMAIN as BUTTON, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from . import setup_integration -from tests.common import snapshot_platform - -ZONE_BYPASS_ID = "button.security_bypass" -PANEL_CLEAR_ID = "button.test_clear_bypass" -PANEL_BYPASS_ID = "button.test_bypass_all" +from tests.common import MockConfigEntry, snapshot_platform async def test_entity_registry( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test the button is registered in entity registry.""" - entry = await setup_platform(hass, BUTTON) + with patch("homeassistant.components.totalconnect.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - ("entity_id", "tcc_request"), - [ - (ZONE_BYPASS_ID, "total_connect_client.zone.TotalConnectZone.bypass"), - ( - PANEL_BYPASS_ID, - "total_connect_client.location.TotalConnectLocation.zone_bypass_all", - ), - ], -) async def test_bypass_button( - hass: HomeAssistant, entity_id: str, tcc_request: str + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_location: AsyncMock, ) -> None: """Test pushing a bypass button.""" - responses = [FailedToBypassZone, None] - await setup_platform(hass, BUTTON) - with patch(tcc_request, side_effect=responses) as mock_request: - # try to bypass, but fails - with pytest.raises(FailedToBypassZone): - await hass.services.async_call( - domain=BUTTON, - service=SERVICE_PRESS, - service_data={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert mock_request.call_count == 1 - - # try to bypass, works this time - await hass.services.async_call( - domain=BUTTON, - service=SERVICE_PRESS, - service_data={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert mock_request.call_count == 2 - - -async def test_clear_button(hass: HomeAssistant) -> None: - """Test pushing the clear bypass button.""" - data = {ATTR_ENTITY_ID: PANEL_CLEAR_ID} - await setup_platform(hass, BUTTON) - TOTALCONNECT_REQUEST = ( - "total_connect_client.location.TotalConnectLocation.clear_bypass" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.security_bypass"}, + blocking=True, ) - with patch(TOTALCONNECT_REQUEST) as mock_request: - await hass.services.async_call( - domain=BUTTON, - service=SERVICE_PRESS, - service_data=data, - blocking=True, - ) - assert mock_request.call_count == 1 + assert mock_location.zones[2].bypass.call_count == 1 + + +async def test_clear_button( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_location: AsyncMock, +) -> None: + """Test pushing the clear bypass button.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_clear_bypass"}, + blocking=True, + ) + + assert mock_location.clear_bypass.call_count == 1 diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index b7ac42c84b5..dbbff265129 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the TotalConnect config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from total_connect_client.exceptions import AuthenticationError @@ -11,217 +11,235 @@ from homeassistant.components.totalconnect.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_LOCATION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .common import ( - CONFIG_DATA, - CONFIG_DATA_NO_USERCODES, - RESPONSE_DISARMED, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_SESSION_DETAILS, - RESPONSE_SUCCESS, - RESPONSE_USER_CODE_INVALID, - TOTALCONNECT_GET_CONFIG, - TOTALCONNECT_REQUEST, - TOTALCONNECT_REQUEST_TOKEN, - USERNAME, - init_integration, -) +from . import setup_integration +from .const import LOCATION_ID, PASSWORD, USERNAME from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: AsyncMock +) -> None: """Test user step.""" - # user starts with no data entered, so show the user form result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=None, + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) -async def test_user_show_locations(hass: HomeAssistant) -> None: - """Test user locations form.""" - # user/pass provided, so check if valid then ask for usercodes on locations form - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_USER_CODE_INVALID, - RESPONSE_SUCCESS, - ] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" - with ( - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - patch( - "homeassistant.components.totalconnect.async_setup_entry", return_value=True - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA_NO_USERCODES, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_PASSWORD: PASSWORD, + CONF_USERNAME: USERNAME, + CONF_USERCODES: {LOCATION_ID: "7890"}, + } + assert result["title"] == "Total Connect" + assert result["options"] == {} + assert result["result"].unique_id == USERNAME + + +async def test_login_errors( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: AsyncMock +) -> None: + """Test login errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient", + ) as client: + client.side_effect = AuthenticationError() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} ) - # first it should show the locations form - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "locations" - # client should have sent four requests for init - assert mock_request.call_count == 4 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} - # user enters an invalid usercode - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_USERCODES: "bad"}, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "locations" - # client should have sent 5th request to validate usercode - assert mock_request.call_count == 5 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) - # user enters a valid usercode - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={CONF_USERCODES: "7890"}, - ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - # client should have sent another request to validate usercode - assert mock_request.call_count == 6 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: +async def test_usercode_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_location: AsyncMock, +) -> None: + """Test user step with usercode errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" + + mock_location.set_usercode.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" + assert result["errors"] == {CONF_LOCATION: "usercode"} + + mock_location.set_usercode.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_no_locations( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_location: AsyncMock, +) -> None: + """Test no locations found.""" + + mock_client.get_number_locations.return_value = 0 + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_locations" + + +async def test_abort_if_already_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test abort if the account is already setup.""" - MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - unique_id=USERNAME, - ).add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - # Should fail, same USERNAME (flow) - with patch("homeassistant.components.totalconnect.config_flow.TotalConnectClient"): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_login_failed(hass: HomeAssistant) -> None: - """Test when we have errors during login.""" - with patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient" - ) as client_mock: - client_mock.side_effect = AuthenticationError() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) +async def test_reauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test login errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - unique_id=USERNAME, - ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient" - ) as client_mock, - patch( - "homeassistant.components.totalconnect.async_setup_entry", return_value=True - ), - ): - # first test with an invalid password - client_mock.side_effect = AuthenticationError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "abc"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_PASSWORD] == "abc" + + +async def test_reauth_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test login errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient", + ) as client: + client.side_effect = AuthenticationError() result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PASSWORD: "password"} + result["flow_id"], {CONF_PASSWORD: PASSWORD} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} - # now test with the password valid - client_mock.side_effect = None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PASSWORD: "password"} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: PASSWORD} + ) - assert len(hass.config_entries.async_entries()) == 1 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" -async def test_no_locations(hass: HomeAssistant) -> None: - """Test with no user locations.""" - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - ] - - with ( - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - patch( - "homeassistant.components.totalconnect.async_setup_entry", return_value=True - ), - patch( - "homeassistant.components.totalconnect.TotalConnectClient.get_number_locations", - return_value=0, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA_NO_USERCODES, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_locations" - await hass.async_block_till_done() - - assert mock_request.call_count == 1 - - -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test config flow options.""" - config_entry = await init_integration(hass) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -231,8 +249,4 @@ async def test_options_flow(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} - await hass.async_block_till_done() - - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + assert mock_config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} diff --git a/tests/components/totalconnect/test_diagnostics.py b/tests/components/totalconnect/test_diagnostics.py index 2ad05c60936..7422ee36143 100644 --- a/tests/components/totalconnect/test_diagnostics.py +++ b/tests/components/totalconnect/test_diagnostics.py @@ -1,36 +1,29 @@ """Test TotalConnect diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant -from .common import LOCATION_ID, init_integration +from . import setup_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + mock_client: AsyncMock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass) + await setup_integration(hass, mock_config_entry) - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - client = result["client"] - assert client["invalid_credentials"] is False - - user = result["user"] - assert user["master"] is False - - location = result["locations"][0] - assert location["location_id"] == LOCATION_ID - - device = location["devices"][0] - assert device["serial_number"] == REDACTED - - partition = location["partitions"][0] - assert partition["name"] == "Test1" - - zone = location["zones"][0] - assert zone["zone_id"] == "1" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/totalconnect/test_init.py b/tests/components/totalconnect/test_init.py index ba533e19798..b19f585965f 100644 --- a/tests/components/totalconnect/test_init.py +++ b/tests/components/totalconnect/test_init.py @@ -4,29 +4,23 @@ from unittest.mock import patch from total_connect_client.exceptions import AuthenticationError -from homeassistant.components.totalconnect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .common import CONFIG_DATA +from . import setup_integration from tests.common import MockConfigEntry -async def test_reauth_started(hass: HomeAssistant) -> None: +async def test_reauth_start( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: """Test that reauth is started when we have login errors.""" - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - ) - mock_entry.add_to_hass(hass) - with patch( "homeassistant.components.totalconnect.TotalConnectClient", ) as mock_client: mock_client.side_effect = AuthenticationError() - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) - assert mock_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index c8251bccd4f..ed5f935f286 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -420,7 +420,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -430,7 +429,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 84cc8f73bf3..37cfb4a36c0 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -602,7 +602,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -612,7 +611,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index f50c5d70362..b17b30bbbb4 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -72,7 +72,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -82,7 +81,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index df63291175a..01738bff943 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -82,7 +82,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -92,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': , }) diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index ad0321accef..b48ef8d336b 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -186,7 +186,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -196,7 +195,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index 5ff1d9c5458..da4dc26317b 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index 9fc5181c45d..1db6e3cf57d 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 5c22c2f7d83..05d645552bb 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index 761df4fcf21..45bad203bc9 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 4b04587db05..6d1d2fa3bad 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index 68d14270b55..e3a7f1d95fe 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py index 0418e4a5a72..7270a77fef1 100644 --- a/tests/components/traccar_server/test_config_flow.py +++ b/tests/components/traccar_server/test_config_flow.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock import pytest -from pytraccar import TraccarException +from pytraccar import TraccarAuthenticationException, TraccarException from homeassistant import config_entries from homeassistant.components.traccar_server.const import ( @@ -175,3 +175,98 @@ async def test_abort_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_traccar_api_client: Generator[AsyncMock], +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify the config entry was updated + assert mock_config_entry.data[CONF_USERNAME] == "new-username" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TraccarAuthenticationException, "invalid_auth"), + (TraccarException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_traccar_api_client: Generator[AsyncMock], + side_effect: Exception, + error: str, +) -> None: + """Test reauth flow with errors.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + mock_traccar_api_client.get_server.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Test recovery after error + mock_traccar_api_client.get_server.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 4ff6213d082..22700376b26 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import trend from homeassistant.components.trend.config_flow import ConfigFlowHandler from homeassistant.components.trend.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +200,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(trend_config_entry.entry_id) @@ -214,7 +215,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -225,6 +226,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the trend config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the trend config entry is removed + assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the trend config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -241,7 +289,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -258,7 +306,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -285,7 +336,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -300,7 +351,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the entity is no longer linked to the source device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id is None + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -333,7 +388,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert trend_config_entry.entry_id not in sensor_device_2.config_entries @@ -350,11 +405,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is moved to the other device + # Check that the entity is linked to the other device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_device_2.id + + # Check that the trend config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert trend_config_entry.entry_id in sensor_device_2.config_entries + assert trend_config_entry.entry_id not in sensor_device_2.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -379,7 +438,7 @@ async def test_async_handle_source_entity_new_entity_id( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -397,12 +456,83 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the trend config entry is updated with the new entity ID assert trend_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes trend config entry from device.""" + + trend_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": sensor_entity_entry.entity_id, + "invert": False, + }, + title="My trend", + version=1, + minor_version=1, + ) + trend_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=trend_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + assert trend_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + assert trend_config_entry.version == 1 + assert trend_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": "sensor.test", + "invert": False, + }, + title="My trend", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py index 8648ca95e93..308d3bb0fca 100644 --- a/tests/components/tts/test_entity.py +++ b/tests/components/tts/test_entity.py @@ -175,3 +175,31 @@ def test_streaming_supported() -> None: sync_non_streaming_entity = SyncNonStreamingEntity() assert sync_non_streaming_entity.async_supports_streaming_input() is False + + +async def test_internal_get_tts_audio_writes_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test that only async_internal_get_tts_audio updates and writes the state.""" + + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + assert config_entry.state is ConfigEntryState.LOADED + state1 = hass.states.get(entity_id) + assert state1 is not None + + # State should *not* change with external method + await mock_tts_entity.async_get_tts_audio("test message", hass.config.language, {}) + state2 = hass.states.get(entity_id) + assert state2 is not None + assert state1.state == state2.state + + # State *should* change with internal method + await mock_tts_entity.async_internal_get_tts_audio( + "test message", hass.config.language, {} + ) + state3 = hass.states.get(entity_id) + assert state3 is not None + assert state1.state != state3.state diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index ccb62959eba..be155aae182 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -916,6 +916,29 @@ async def test_web_view_wrong_file( assert req.status == HTTPStatus.NOT_FOUND +@pytest.mark.parametrize( + ("setup", "expected_url_suffix"), + [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], + indirect=["setup"], +) +async def test_web_view_wrong_file_with_head_request( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: str, + expected_url_suffix: str, +) -> None: + """Set up a TTS platform and receive wrong file from web.""" + client = await hass_client() + + url = ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" + ) + + req = await client.head(url) + assert req.status == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize( ("setup", "expected_url_suffix"), [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], @@ -1812,7 +1835,7 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: async def bad_data_gen(): yield bytes(0) - with pytest.raises(RuntimeError): + with pytest.raises(HomeAssistantError): # Simulate a bad WAV file async for _chunk in tts._async_convert_audio( hass, "wav", bad_data_gen(), "mp3" @@ -2009,3 +2032,34 @@ async def test_tts_cache() -> None: assert await consume_mid_data_task == b"012" with pytest.raises(ValueError): assert await consume_pre_data_loaded_task == b"012" + + +async def test_async_internal_get_tts_audio_called( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, + hass_client: ClientSessionGenerator, +) -> None: + """Test that non-streaming entity has its async_internal_get_tts_audio method called.""" + + await mock_config_entry_setup(hass, mock_tts_entity) + + # Non-streaming + assert mock_tts_entity.async_supports_streaming_input() is False + + with patch( + "homeassistant.components.tts.entity.TextToSpeechEntity.async_internal_get_tts_audio" + ) as internal_get_tts_audio: + media_source_id = tts.generate_media_source_id( + hass, + "test message", + "tts.test", + "en_US", + cache=None, + ) + + url = await get_media_source_url(hass, media_source_id) + client = await hass_client() + await client.get(url) + + # async_internal_get_tts_audio is called + internal_get_tts_audio.assert_called_once_with("test message", "en_US", {}) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 56bfc0867c6..246a4388c76 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -1 +1,263 @@ """Tests for the Tuya component.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import DeviceListener, ManagerCompat +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DEVICE_MOCKS = [ + "cl_3r8gc33pnqsxfe1g", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cl_cpbo62rn", # https://github.com/orgs/home-assistant/discussions/539 + "cl_ebt12ypvexnixvtf", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cl_g1cp07dsqnbdbbki", # https://github.com/home-assistant/core/issues/139966 + "cl_qqdxfdht", # https://github.com/orgs/home-assistant/discussions/539 + "cl_zah67ekd", # https://github.com/home-assistant/core/issues/71242 + "clkg_nhyj64w2", # https://github.com/home-assistant/core/issues/136055 + "co2bj_yrr3eiyiacm31ski", # https://github.com/home-assistant/core/issues/133173 + "cobj_hcdy5zrq3ikzthws", # https://github.com/orgs/home-assistant/discussions/482 + "cs_ipmyy4nigpqcnd8q", # https://github.com/home-assistant/core/pull/148726 + "cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865 + "cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278 + "cs_vmxuxszzjwp5smli", # https://github.com/home-assistant/core/issues/119865 + "cs_zibqa9dutqyaxym2", # https://github.com/home-assistant/core/pull/125098 + "cwjwq_agwu93lr", # https://github.com/orgs/home-assistant/discussions/79 + "cwwsq_wfkzyy0evslzsmoi", # https://github.com/home-assistant/core/issues/144745 + "cwysj_akln8rb04cav403q", # https://github.com/home-assistant/core/pull/146599 + "cwysj_z3rpyvznfcch99aa", # https://github.com/home-assistant/core/pull/146599 + "cz_0g1fmqh6d5io7lcn", # https://github.com/home-assistant/core/issues/149704 + "cz_2iepauebcvo74ujc", # https://github.com/home-assistant/core/issues/141278 + "cz_2jxesipczks0kdct", # https://github.com/home-assistant/core/issues/147149 + "cz_37mnhia3pojleqfh", # https://github.com/home-assistant/core/issues/146164 + "cz_39sy2g68gsjwo2xv", # https://github.com/home-assistant/core/issues/141278 + "cz_6fa7odsufen374x2", # https://github.com/home-assistant/core/issues/150029 + "cz_9ivirni8wemum6cw", # https://github.com/home-assistant/core/issues/139735 + "cz_CHLZe9HQ6QIXujVN", # https://github.com/home-assistant/core/issues/149233 + "cz_HBRBzv1UVBVfF6SL", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_anwgf2xugjxpkfxb", # https://github.com/orgs/home-assistant/discussions/539 + "cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704 + "cz_dntgh2ngvshfxpsz", # https://github.com/home-assistant/core/issues/149704 + "cz_fencxse0bnut96ig", # https://github.com/home-assistant/core/issues/63978 + "cz_gbtxrqfy9xcsakyp", # https://github.com/home-assistant/core/issues/141278 + "cz_gjnozsaz", # https://github.com/orgs/home-assistant/discussions/482 + "cz_hA2GsgMfTQFTz9JL", # https://github.com/home-assistant/core/issues/148347 + "cz_hj0a5c7ckzzexu8l", # https://github.com/home-assistant/core/issues/149704 + "cz_ik9sbig3mthx9hjz", # https://github.com/home-assistant/core/issues/141278 + "cz_ipabufmlmodje1ws", # https://github.com/home-assistant/core/issues/63978 + "cz_iqhidxhhmgxk5eja", # https://github.com/home-assistant/core/issues/149233 + "cz_jnbbxsb84gvvyfg5", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_n8iVBAPLFKAAAszH", # https://github.com/home-assistant/core/issues/146164 + "cz_nkb0fmtlfyqosnvk", # https://github.com/orgs/home-assistant/discussions/482 + "cz_nx8rv6jpe1tsnffk", # https://github.com/home-assistant/core/issues/148347 + "cz_qm0iq4nqnrlzh4qc", # https://github.com/home-assistant/core/issues/141278 + "cz_raceucn29wk2yawe", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_sb6bwb1n8ma2c5q4", # https://github.com/home-assistant/core/issues/141278 + "cz_t0a4hwsf8anfsadp", # https://github.com/home-assistant/core/issues/149704 + "cz_tf6qp8t3hl9h7m94", # https://github.com/home-assistant/core/issues/143209 + "cz_tkn2s79mzedk6pwr", # https://github.com/home-assistant/core/issues/146164 + "cz_vxqn72kwtosoy4d3", # https://github.com/home-assistant/core/issues/141278 + "cz_w0qqde0g", # https://github.com/orgs/home-assistant/discussions/482 + "cz_wifvoilfrqeo6hvu", # https://github.com/home-assistant/core/issues/146164 + "cz_wrz6vzch8htux2zp", # https://github.com/home-assistant/core/issues/141278 + "cz_y4jnobxh", # https://github.com/orgs/home-assistant/discussions/482 + "cz_z6pht25s3p0gs26q", # https://github.com/home-assistant/core/issues/63978 + "dc_l3bpgg8ibsagon4x", # https://github.com/home-assistant/core/issues/149704 + "dd_gaobbrxqiblcng2p", # https://github.com/home-assistant/core/issues/149233 + "dj_0gyaslysqfp4gfis", # https://github.com/home-assistant/core/issues/149895 + "dj_8szt7whdvwpmxglk", # https://github.com/home-assistant/core/issues/149704 + "dj_8y0aquaa8v6tho8w", # https://github.com/home-assistant/core/issues/149704 + "dj_AqHUMdcbYzIq1Of4", # https://github.com/orgs/home-assistant/discussions/539 + "dj_amx1bgdrfab6jngb", # https://github.com/orgs/home-assistant/discussions/482 + "dj_bSXSSFArVKtc4DyC", # https://github.com/orgs/home-assistant/discussions/539 + "dj_baf9tt9lb8t5uc7z", # https://github.com/home-assistant/core/issues/149704 + "dj_c3nsqogqovapdpfj", # https://github.com/home-assistant/core/issues/146164 + "dj_d4g0fbsoaal841o6", # https://github.com/home-assistant/core/issues/149704 + "dj_dbou1ap4", # https://github.com/orgs/home-assistant/discussions/482 + "dj_djnozmdyqyriow8z", # https://github.com/home-assistant/core/issues/149704 + "dj_ekwolitfjhxn55js", # https://github.com/home-assistant/core/issues/149704 + "dj_fuupmcr2mb1odkja", # https://github.com/home-assistant/core/issues/149704 + "dj_hp6orhaqm6as3jnv", # https://github.com/home-assistant/core/issues/149704 + "dj_hpc8ddyfv85haxa7", # https://github.com/home-assistant/core/issues/149704 + "dj_iayz2jmtlipjnxj7", # https://github.com/home-assistant/core/issues/149704 + "dj_idnfq7xbx8qewyoa", # https://github.com/home-assistant/core/issues/149704 + "dj_ilddqqih3tucdk68", # https://github.com/home-assistant/core/issues/149704 + "dj_j1bgp31cffutizub", # https://github.com/home-assistant/core/issues/149704 + "dj_lmnt3uyltk1xffrt", # https://github.com/home-assistant/core/issues/149704 + "dj_mki13ie507rlry4r", # https://github.com/home-assistant/core/pull/126242 + "dj_nbumqpv8vz61enji", # https://github.com/home-assistant/core/issues/149704 + "dj_nlxvjzy1hoeiqsg6", # https://github.com/home-assistant/core/issues/149704 + "dj_oe0cpnjg", # https://github.com/home-assistant/core/issues/149704 + "dj_qoqolwtqzfuhgghq", # https://github.com/home-assistant/core/issues/149233 + "dj_riwp3k79", # https://github.com/home-assistant/core/issues/149704 + "dj_tgewj70aowigv8fz", # https://github.com/orgs/home-assistant/discussions/539 + "dj_tmsloaroqavbucgn", # https://github.com/home-assistant/core/issues/149704 + "dj_ufq2xwuzd4nb0qdr", # https://github.com/home-assistant/core/issues/149704 + "dj_vqwcnabamzrc2kab", # https://github.com/home-assistant/core/issues/149704 + "dj_xdvitmhhmgefaeuq", # https://github.com/home-assistant/core/issues/146164 + "dj_xokdfs6kh5ednakk", # https://github.com/home-assistant/core/issues/149704 + "dj_zakhnlpdiu0ycdxn", # https://github.com/home-assistant/core/issues/149704 + "dj_zav1pa32pyxray78", # https://github.com/home-assistant/core/issues/149704 + "dj_zputiamzanuk6yky", # https://github.com/home-assistant/core/issues/149704 + "dlq_0tnvg2xaisqdadcf", # https://github.com/home-assistant/core/issues/102769 + "dlq_cnpkf4xdmd9v49iq", # https://github.com/home-assistant/core/pull/149320 + "dlq_jdj6ccklup7btq3a", # https://github.com/home-assistant/core/issues/143209 + "dlq_kxdr6su0c55p7bbo", # https://github.com/home-assistant/core/issues/143499 + "dlq_r9kg2g1uhhyicycb", # https://github.com/home-assistant/core/issues/149650 + "dlq_z3jngbyubvwgfrcv", # https://github.com/home-assistant/core/issues/150293 + "dr_pjvxl1wsyqxivsaf", # https://github.com/home-assistant/core/issues/84869 + "fs_g0ewlb1vmwqljzji", # https://github.com/home-assistant/core/issues/141231 + "fs_ibytpo6fpnugft1c", # https://github.com/home-assistant/core/issues/135541 + "fsd_9ecs16c53uqskxw6", # https://github.com/home-assistant/core/issues/149233 + "gyd_lgekqfxdabipm3tn", # https://github.com/home-assistant/core/issues/133173 + "hps_2aaelwxk", # https://github.com/home-assistant/core/issues/149704 + "hps_wqashyqo", # https://github.com/home-assistant/core/issues/146180 + "hwsb_ircs2n82vgrozoew", # https://github.com/home-assistant/core/issues/149233 + "jtmspro_xqeob8h6", # https://github.com/orgs/home-assistant/discussions/517 + "kg_4nqs33emdwJxpQ8O", # https://github.com/orgs/home-assistant/discussions/539 + "kg_5ftkaulg", # https://github.com/orgs/home-assistant/discussions/539 + "kg_gbm9ata1zrzaez4a", # https://github.com/home-assistant/core/issues/148347 + "kj_CAjWAxBUZt7QZHfz", # https://github.com/home-assistant/core/issues/146023 + "kj_fsxtzzhujkrak2oy", # https://github.com/orgs/home-assistant/discussions/439 + "kj_s4uzibibgzdxzowo", # https://github.com/home-assistant/core/issues/150246 + "kj_yrzylxax1qspdgpp", # https://github.com/orgs/home-assistant/discussions/61 + "ks_j9fa8ahzac8uvlfl", # https://github.com/orgs/home-assistant/discussions/329 + "kt_5wnlzekkstwcdsvm", # https://github.com/home-assistant/core/pull/148646 + "kt_ibmmirhhq62mmf1g", # https://github.com/home-assistant/core/pull/150077 + "kt_vdadlnmsorlhw4td", # https://github.com/home-assistant/core/pull/149635 + "ldcg_9kbbfeho", # https://github.com/orgs/home-assistant/discussions/482 + "mal_gyitctrjj1kefxp2", # Alarm Host support + "mc_oSQljE9YDqwCwTUA", # https://github.com/home-assistant/core/issues/149233 + "mcs_6ywsnauy", # https://github.com/orgs/home-assistant/discussions/482 + "mcs_7jIGJAymiH8OsFFb", # https://github.com/home-assistant/core/issues/108301 + "mcs_8yhypbo7", # https://github.com/orgs/home-assistant/discussions/482 + "mcs_hx5ztlztij4yxxvg", # https://github.com/home-assistant/core/issues/148347 + "mcs_qxu3flpqjsc1kqu3", # https://github.com/home-assistant/core/issues/141278 + "mzj_qavcakohisj5adyh", # https://github.com/home-assistant/core/issues/141278 + "ntq_9mqdhwklpvnnvb7t", # https://github.com/orgs/home-assistant/discussions/517 + "pc_t2afic7i3v1bwhfp", # https://github.com/home-assistant/core/issues/149704 + "pc_trjopo1vdlt9q1tg", # https://github.com/home-assistant/core/issues/149704 + "pc_tsbguim4trl6fa7g", # https://github.com/home-assistant/core/issues/146164 + "pc_yku9wsimasckdt15", # https://github.com/orgs/home-assistant/discussions/482 + "pir_3amxzozho9xp4mkh", # https://github.com/home-assistant/core/issues/149704 + "pir_fcdjzz3s", # https://github.com/home-assistant/core/issues/149704 + "pir_wqz93nrdomectyoz", # https://github.com/home-assistant/core/issues/149704 + "qccdz_7bvgooyjhiua1yyq", # https://github.com/home-assistant/core/issues/136207 + "qn_5ls2jw49hpczwqng", # https://github.com/home-assistant/core/issues/149233 + "qxj_fsea1lat3vuktbt6", # https://github.com/orgs/home-assistant/discussions/318 + "qxj_is2indt9nlth6esa", # https://github.com/home-assistant/core/issues/136472 + "rqbj_4iqe2hsfyd86kwwc", # https://github.com/orgs/home-assistant/discussions/100 + "sd_i6hyjg3af7doaswm", # https://github.com/orgs/home-assistant/discussions/539 + "sd_lr33znaodtyarrrz", # https://github.com/home-assistant/core/issues/141278 + "sfkzq_1fcnd8xk", # https://github.com/orgs/home-assistant/discussions/539 + "sfkzq_ed7frwissyqrejic", # https://github.com/home-assistant/core/pull/149236 + "sfkzq_o6dagifntoafakst", # https://github.com/home-assistant/core/issues/148116 + "sfkzq_rzklytdei8i8vo37", # https://github.com/home-assistant/core/issues/146164 + "sgbj_ulv4nnue7gqp0rjk", # https://github.com/home-assistant/core/issues/149704 + "sj_tgvtvdoc", # https://github.com/orgs/home-assistant/discussions/482 + "sp_drezasavompxpcgm", # https://github.com/home-assistant/core/issues/149704 + "sp_nzauwyj3mcnjnf35", # https://github.com/home-assistant/core/issues/141278 + "sp_rjKXWRohlvOTyLBu", # https://github.com/home-assistant/core/issues/149704 + "sp_rudejjigkywujjvs", # https://github.com/home-assistant/core/issues/146164 + "sp_sdd5f5f2dl5wydjf", # https://github.com/home-assistant/core/issues/144087 + "tdq_1aegphq4yfd50e6b", # https://github.com/home-assistant/core/issues/143209 + "tdq_9htyiowaf5rtdhrv", # https://github.com/home-assistant/core/issues/143209 + "tdq_cq1p0nt0a4rixnex", # https://github.com/home-assistant/core/issues/146845 + "tdq_nockvv2k39vbrxxk", # https://github.com/home-assistant/core/issues/145849 + "tdq_pu8uhxhwcp3tgoz7", # https://github.com/home-assistant/core/issues/141278 + "tdq_uoa3mayicscacseb", # https://github.com/home-assistant/core/issues/128911 + "tyndj_pyakuuoc", # https://github.com/home-assistant/core/issues/149704 + "wfcon_b25mh8sxawsgndck", # https://github.com/home-assistant/core/issues/149704 + "wfcon_lieerjyy6l4ykjor", # https://github.com/home-assistant/core/issues/136055 + "wg2_haclbl0qkqlf2qds", # https://github.com/orgs/home-assistant/discussions/517 + "wg2_nwxr8qcu4seltoro", # https://github.com/orgs/home-assistant/discussions/430 + "wg2_setmxeqgs63xwopm", # https://github.com/orgs/home-assistant/discussions/539 + "wg2_v7owd9tzcaninc36", # https://github.com/orgs/home-assistant/discussions/539 + "wk_6kijc7nd", # https://github.com/home-assistant/core/issues/136513 + "wk_aqoouq7x", # https://github.com/home-assistant/core/issues/146263 + "wk_ccpwojhalfxryigz", # https://github.com/home-assistant/core/issues/145551 + "wk_fi6dne5tu4t1nm6j", # https://github.com/orgs/home-assistant/discussions/243 + "wk_gc1bxoq2hafxpa35", # https://github.com/home-assistant/core/issues/145551 + "wk_gogb05wrtredz3bs", # https://github.com/home-assistant/core/issues/136337 + "wk_y5obtqhuztqsf2mj", # https://github.com/home-assistant/core/issues/139735 + "wkcz_gc4b1mdw7kebtuyz", # https://github.com/home-assistant/core/issues/135617 + "wnykq_npbbca46yiug8ysk", # https://github.com/orgs/home-assistant/discussions/539 + "wnykq_rqhxdyusjrwxyff6", # https://github.com/home-assistant/core/issues/133173 + "wsdcg_g2y6z3p3ja2qhyav", # https://github.com/home-assistant/core/issues/102769 + "wsdcg_iq4ygaai", # https://github.com/orgs/home-assistant/discussions/482 + "wsdcg_iv7hudlj", # https://github.com/home-assistant/core/issues/141278 + "wsdcg_krlcihrpzpc8olw9", # https://github.com/orgs/home-assistant/discussions/517 + "wsdcg_lf36y5nwb8jkxwgg", # https://github.com/orgs/home-assistant/discussions/539 + "wsdcg_vtA4pDd6PLUZzXgZ", # https://github.com/orgs/home-assistant/discussions/482 + "wsdcg_xr3htd96", # https://github.com/orgs/home-assistant/discussions/482 + "wsdcg_yqiqbaldtr0i7mru", # https://github.com/home-assistant/core/issues/136223 + "wxkg_ja5osu5g", # https://github.com/orgs/home-assistant/discussions/482 + "wxkg_l8yaz4um5b3pwyvf", # https://github.com/home-assistant/core/issues/93975 + "ydkt_jevroj5aguwdbs2e", # https://github.com/orgs/home-assistant/discussions/288 + "ygsb_l6ax0u6jwbz82atk", # https://github.com/home-assistant/core/issues/146319 + "ykq_bngwdjsr", # https://github.com/orgs/home-assistant/discussions/482 + "ywbj_arywmw6h6vesoz5t", # https://github.com/home-assistant/core/issues/146164 + "ywbj_cjlutkuuvxnie17o", # https://github.com/home-assistant/core/issues/146164 + "ywbj_gf9dejhmzffgdyfj", # https://github.com/home-assistant/core/issues/149704 + "ywbj_kscbebaf3s1eogvt", # https://github.com/home-assistant/core/issues/141278 + "ywbj_rccxox8p", # https://github.com/orgs/home-assistant/discussions/625 + "ywcgq_h8lvyoahr6s6aybf", # https://github.com/home-assistant/core/issues/145932 + "ywcgq_wtzwyhkev3b4ubns", # https://github.com/home-assistant/core/issues/103818 + "zjq_nkkl7uzv", # https://github.com/orgs/home-assistant/discussions/482 + "zndb_4ggkyflayu1h1ho9", # https://github.com/home-assistant/core/pull/149317 + "zndb_v5jlnn5hwyffkhp3", # https://github.com/home-assistant/core/issues/143209 + "zndb_ze8faryrxr0glqnn", # https://github.com/home-assistant/core/issues/138372 + "znnbq_6b3pbbuqbfabhfiq", # https://github.com/orgs/home-assistant/discussions/707 + "znrb_db81ge24jctwx8lo", # https://github.com/home-assistant/core/issues/136513 + "zwjcy_myd45weu", # https://github.com/orgs/home-assistant/discussions/482 +] + + +class MockDeviceListener(DeviceListener): + """Mocked DeviceListener for testing.""" + + async def async_send_device_update( + self, + hass: HomeAssistant, + device: CustomerDevice, + updated_status_properties: dict[str, Any] | None = None, + ) -> None: + """Mock update device method.""" + property_list: list[str] = [] + if updated_status_properties: + for key, value in updated_status_properties.items(): + if key not in device.status: + raise ValueError( + f"Property {key} not found in device status: {device.status}" + ) + device.status[key] = value + property_list.append(key) + self.update_device(device, property_list) + await hass.async_block_till_done() + + +async def initialize_entry( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: CustomerDevice | list[CustomerDevice], +) -> None: + """Initialize the Tuya component with a mock manager and config entry.""" + if not isinstance(mock_devices, list): + mock_devices = [mock_devices] + mock_manager.device_map = {device.id: device for device in mock_devices} + + # Setup + mock_config_entry.add_to_hass(hass) + + # Initialize the component + with patch( + "homeassistant.components.tuya.ManagerCompat", return_value=mock_manager + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tuya/common.py b/tests/components/tuya/common.py deleted file mode 100644 index 8dcef136b7f..00000000000 --- a/tests/components/tuya/common.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Test code shared between test files.""" - -from tuyaha.devices import climate, light, switch - -CLIMATE_ID = "1" -CLIMATE_DATA = { - "data": {"state": "true", "temp_unit": climate.UNIT_CELSIUS}, - "id": CLIMATE_ID, - "ha_type": "climate", - "name": "TestClimate", - "dev_type": "climate", -} - -LIGHT_ID = "2" -LIGHT_DATA = { - "data": {"state": "true"}, - "id": LIGHT_ID, - "ha_type": "light", - "name": "TestLight", - "dev_type": "light", -} - -SWITCH_ID = "3" -SWITCH_DATA = { - "data": {"state": True}, - "id": SWITCH_ID, - "ha_type": "switch", - "name": "TestSwitch", - "dev_type": "switch", -} - -LIGHT_ID_FAKE1 = "9998" -LIGHT_DATA_FAKE1 = { - "data": {"state": "true"}, - "id": LIGHT_ID_FAKE1, - "ha_type": "light", - "name": "TestLightFake1", - "dev_type": "light", -} - -LIGHT_ID_FAKE2 = "9999" -LIGHT_DATA_FAKE2 = { - "data": {"state": "true"}, - "id": LIGHT_ID_FAKE2, - "ha_type": "light", - "name": "TestLightFake2", - "dev_type": "light", -} - -TUYA_DEVICES = [ - climate.TuyaClimate(CLIMATE_DATA, None), - light.TuyaLight(LIGHT_DATA, None), - switch.TuyaSwitch(SWITCH_DATA, None), - light.TuyaLight(LIGHT_DATA_FAKE1, None), - light.TuyaLight(LIGHT_DATA_FAKE2, None), -] - - -class MockTuya: - """Mock for Tuya devices.""" - - def get_all_devices(self): - """Return all configured devices.""" - return TUYA_DEVICES - - def get_device_by_id(self, dev_id): - """Return configured device with dev id.""" - if dev_id == LIGHT_ID_FAKE1: - return None - if dev_id == LIGHT_ID_FAKE2: - return switch.TuyaSwitch(SWITCH_DATA, None) - for device in TUYA_DEVICES: - if device.object_id() == dev_id: - return device - return None diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 4fffb3ae389..08ede9b73d9 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -6,10 +6,24 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from tuya_sharing import CustomerApi, CustomerDevice, DeviceFunction, DeviceStatusRange -from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya.const import ( + CONF_APP_TYPE, + CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import json_dumps +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from . import DEVICE_MOCKS, MockDeviceListener + +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -25,15 +39,44 @@ def mock_old_config_entry() -> MockConfigEntry: @pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Mock an config entry.""" + """Mock a config entry.""" return MockConfigEntry( - title="12345", + title="Test Tuya entry", domain=DOMAIN, - data={CONF_USER_CODE: "12345"}, + data={ + CONF_ENDPOINT: "test_endpoint", + CONF_TERMINAL_ID: "test_terminal", + CONF_TOKEN_INFO: "test_token", + CONF_USER_CODE: "test_user_code", + }, unique_id="12345", ) +@pytest.fixture +async def mock_loaded_entry( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> MockConfigEntry: + """Mock a config entry.""" + # Setup + mock_manager.device_map = { + mock_device.id: mock_device, + } + mock_config_entry.add_to_hass(hass) + + # Initialize the component + with ( + patch("homeassistant.components.tuya.ManagerCompat", return_value=mock_manager), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + @pytest.fixture def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" @@ -68,3 +111,108 @@ def mock_tuya_login_control() -> Generator[MagicMock]: }, ) yield login_control + + +@pytest.fixture +def mock_manager() -> ManagerCompat: + """Mock Tuya Manager.""" + manager = MagicMock(spec=ManagerCompat) + manager.device_map = {} + manager.mq = MagicMock() + manager.mq.client = MagicMock() + manager.mq.client.is_connected = MagicMock(return_value=True) + manager.customer_api = MagicMock(spec=CustomerApi) + # Meaningless URL / UUIDs + manager.customer_api.endpoint = "https://apigw.tuyaeu.com" + manager.terminal_id = "7cd96aff-6ec8-4006-b093-3dbff7947591" + return manager + + +@pytest.fixture +def mock_device_code() -> str: + """Fixture to parametrize the type of the mock device. + + To set a configuration, tests can be marked with: + @pytest.mark.parametrize("mock_device_code", ["device_code_1", "device_code_2"]) + """ + return None + + +@pytest.fixture +async def mock_devices(hass: HomeAssistant) -> list[CustomerDevice]: + """Load all Tuya CustomerDevice fixtures. + + Use this to generate global snapshots for each platform. + """ + return [await _create_device(hass, device_code) for device_code in DEVICE_MOCKS] + + +@pytest.fixture +async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDevice: + """Load a single Tuya CustomerDevice fixture. + + Use this for testing behavior on a specific device. + """ + return await _create_device(hass, mock_device_code) + + +async def _create_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDevice: + """Mock a Tuya CustomerDevice.""" + details = await async_load_json_object_fixture( + hass, f"{mock_device_code}.json", DOMAIN + ) + device = MagicMock(spec=CustomerDevice) + + # Use reverse of the product_id for testing + device.id = mock_device_code.replace("_", "")[::-1] + + device.name = details["name"] + device.category = details["category"] + device.product_id = details["product_id"] + device.product_name = details["product_name"] + device.online = details["online"] + device.sub = details.get("sub") + device.time_zone = details.get("time_zone") + device.active_time = details.get("active_time") + if device.active_time: + device.active_time = int(dt_util.as_timestamp(device.active_time)) + device.create_time = details.get("create_time") + if device.create_time: + device.create_time = int(dt_util.as_timestamp(device.create_time)) + device.update_time = details.get("update_time") + if device.update_time: + device.update_time = int(dt_util.as_timestamp(device.update_time)) + device.support_local = details.get("support_local") + device.mqtt_connected = details.get("mqtt_connected") + + device.function = { + key: DeviceFunction( + code=value.get("code"), + type=value["type"], + values=json_dumps(value["value"]), + ) + for key, value in details["function"].items() + } + device.status_range = { + key: DeviceStatusRange( + code=value.get("code"), + type=value["type"], + values=json_dumps(value["value"]), + ) + for key, value in details["status_range"].items() + } + device.status = details["status"] + for key, value in device.status.items(): + if device.status_range[key].type == "Json": + device.status[key] = json_dumps(value) + return device + + +@pytest.fixture +def mock_listener( + hass: HomeAssistant, mock_manager: ManagerCompat +) -> MockDeviceListener: + """Create a DeviceListener for testing.""" + listener = MockDeviceListener(hass, mock_manager) + mock_manager.add_device_listener(listener) + return listener diff --git a/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json new file mode 100644 index 00000000000..189938aa4f0 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json @@ -0,0 +1,122 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lounge Dark Blind", + "model": null, + "category": "cl", + "product_id": "3r8gc33pnqsxfe1g", + "product_name": "Blinds Controller", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-01T20:55:54+00:00", + "create_time": "2021-07-26T15:33:42+00:00", + "update_time": "2022-02-12T10:40:15+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["cancel", "1", "2", "3", "4"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back": { + "type": "Boolean", + "value": {} + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["cancel", "1", "2", "3", "4"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "time_total": { + "type": "Integer", + "value": { + "unit": "ms", + "min": 0, + "max": 120000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "open", + "percent_control": 0, + "percent_state": 0, + "control_back": true, + "work_state": "opening", + "countdown": "cancel", + "countdown_left": 0, + "time_total": 25400 + } +} diff --git a/tests/components/tuya/fixtures/cl_cpbo62rn.json b/tests/components/tuya/fixtures/cl_cpbo62rn.json new file mode 100644 index 00000000000..b52bb31f588 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_cpbo62rn.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "blinds", + "category": "cl", + "product_id": "cpbo62rn", + "product_name": "curtain robot", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2023-06-29T15:14:19+00:00", + "create_time": "2023-06-29T15:14:19+00:00", + "update_time": "2023-06-29T15:14:19+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["morning", "night"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["morning", "night"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["motor_fault"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "stop", + "percent_control": 63, + "percent_state": 64, + "mode": "morning", + "fault": 0, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json new file mode 100644 index 00000000000..fd0ff1fb181 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json @@ -0,0 +1,57 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kitchen Blinds", + "model": "KASMARTBLIA", + "category": "cl", + "product_id": "ebt12ypvexnixvtf", + "product_name": "Smart Blinds", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-13T23:10:34+00:00", + "create_time": "2022-01-13T23:10:34+00:00", + "update_time": "2022-02-12T10:40:15+00:00", + "function": { + "switch_1": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "percent_control": 0 + } +} diff --git a/tests/components/tuya/fixtures/cl_g1cp07dsqnbdbbki.json b/tests/components/tuya/fixtures/cl_g1cp07dsqnbdbbki.json new file mode 100644 index 00000000000..85f4ec91d4b --- /dev/null +++ b/tests/components/tuya/fixtures/cl_g1cp07dsqnbdbbki.json @@ -0,0 +1,102 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Persiana do Quarto", + "category": "cl", + "product_id": "g1cp07dsqnbdbbki", + "product_name": "Smart roller blinds", + "online": true, + "sub": false, + "time_zone": "-03:00", + "active_time": "2023-06-21T04:29:09+00:00", + "create_time": "2023-06-21T04:29:09+00:00", + "update_time": "2023-06-21T04:29:09+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "border": { + "type": "Enum", + "value": { + "range": ["up", "down", "up_delete"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + }, + "border": { + "type": "Enum", + "value": { + "range": ["up", "down", "up_delete"] + } + } + }, + "status": { + "control": "open", + "work_state": "opening", + "percent_state": 0, + "percent_control": 100, + "control_back_mode": "back", + "border": "up" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cl_qqdxfdht.json b/tests/components/tuya/fixtures/cl_qqdxfdht.json new file mode 100644 index 00000000000..c0a7bc1d0ba --- /dev/null +++ b/tests/components/tuya/fixtures/cl_qqdxfdht.json @@ -0,0 +1,65 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "bedroom blinds", + "category": "cl", + "product_id": "qqdxfdht", + "product_name": "Blinds Drive-BLE", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2021-11-09T08:38:29+00:00", + "create_time": "2021-11-09T08:38:29+00:00", + "update_time": "2021-11-09T08:38:29+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": "0", + "max": "100", + "scale": "0", + "step": "1" + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": "0", + "max": "100", + "scale": "0", + "step": "1" + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "work_state": "closing" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cl_zah67ekd.json b/tests/components/tuya/fixtures/cl_zah67ekd.json new file mode 100644 index 00000000000..b1920f1ecc5 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_zah67ekd.json @@ -0,0 +1,60 @@ +{ + "name": "Kitchen Blinds", + "category": "cl", + "product_id": "zah67ekd", + "product_name": "AM43拉绳电机-Zigbee", + "online": true, + "function": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "percent_state": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + }, + "work_state": { + "type": "Enum", + "value": { "range": ["opening", "closing"] } + }, + "situation_set": { + "type": "Enum", + "value": { "range": ["fully_open", "fully_close"] } + }, + "fault": { + "type": "Bitmap", + "value": { "label": ["motor_fault"] } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "percent_state": 52, + "control_back_mode": "forward", + "work_state": "closing", + "situation_set": "fully_open", + "fault": 0 + } +} diff --git a/tests/components/tuya/fixtures/clkg_nhyj64w2.json b/tests/components/tuya/fixtures/clkg_nhyj64w2.json new file mode 100644 index 00000000000..1aa6ebebd2c --- /dev/null +++ b/tests/components/tuya/fixtures/clkg_nhyj64w2.json @@ -0,0 +1,93 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Tapparelle studio", + "category": "clkg", + "product_id": "nhyj64w2", + "product_name": "Curtain switch", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-01-13T23:37:14+00:00", + "create_time": "2025-01-13T23:37:14+00:00", + "update_time": "2025-01-13T23:37:14+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "cur_calibration": "end", + "switch_backlight": true, + "control_back_mode": "forward" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json new file mode 100644 index 00000000000..c4657f30012 --- /dev/null +++ b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json @@ -0,0 +1,172 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "AQI", + "category": "co2bj", + "product_id": "yrr3eiyiacm31ski", + "product_name": "AIR_DETECTOR ", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2025-01-02T05:14:50+00:00", + "create_time": "2025-01-02T05:14:50+00:00", + "update_time": "2025-01-02T05:14:50+00:00", + "function": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "co2_state": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "co2_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 5000, + "scale": 0, + "step": 1 + } + }, + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -9, + "max": 199, + "scale": 0, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "pm25_value": { + "type": "Integer", + "value": { + "unit": "ug/m3", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "voc_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + }, + "ch2o_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + } + }, + "status": { + "co2_state": "normal", + "co2_value": 541, + "alarm_volume": "low", + "alarm_time": 1, + "alarm_switch": false, + "battery_percentage": 100, + "alarm_bright": 98, + "temp_current": 26, + "humidity_value": 53, + "pm25_value": 17, + "voc_value": 18, + "ch2o_value": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cobj_hcdy5zrq3ikzthws.json b/tests/components/tuya/fixtures/cobj_hcdy5zrq3ikzthws.json new file mode 100644 index 00000000000..59e6fc63f1b --- /dev/null +++ b/tests/components/tuya/fixtures/cobj_hcdy5zrq3ikzthws.json @@ -0,0 +1,59 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smogo", + "category": "cobj", + "product_id": "hcdy5zrq3ikzthws", + "product_name": "WIFI smart CO alarm", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-07-19T07:41:22+00:00", + "create_time": "2023-07-19T07:41:22+00:00", + "update_time": "2023-07-19T07:41:22+00:00", + "function": {}, + "status_range": { + "co_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "co_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "checking_result": { + "type": "Enum", + "value": { + "range": ["checking", "check_success", "check_failure", "others"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "co_status": "normal", + "co_value": 0, + "checking_result": "check_success", + "battery_percentage": 97 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json b/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json new file mode 100644 index 00000000000..816c17e17c7 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json @@ -0,0 +1,40 @@ +{ + "name": "Pro Breeze 30L Compressor Dehumidifier", + "category": "cs", + "product_id": "ipmyy4nigpqcnd8q", + "product_name": "30L Dehumidifier with Max Extraction", + "online": true, + "function": { + "anion": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "anion": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["0", "1", "2", "3", "4", "5"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 1440, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "anion": false, + "fault": 0, + "countdown_left": 0 + } +} diff --git a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json new file mode 100644 index 00000000000..2edd120cf8d --- /dev/null +++ b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json @@ -0,0 +1,127 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Dehumidifer", + "category": "cs", + "product_id": "ka2wfrdoogpvgzfi", + "product_name": "Emma Dehumidifier - eeese air care", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-11-06T18:25:00+00:00", + "create_time": "2024-11-06T18:25:00+00:00", + "update_time": "2024-11-06T18:25:00+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "h", + "min": 0, + "max": 24, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["tankfull", "defrost", "E1", "E2", "L3", "L4", "L2"] + } + } + }, + "status": { + "switch": false, + "dehumidify_set_value": 25, + "fan_speed_enum": "low", + "anion": false, + "child_lock": false, + "humidity_indoor": 48, + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json new file mode 100644 index 00000000000..b11dfe88582 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json @@ -0,0 +1,30 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "DryFix", + "category": "cs", + "product_id": "qhxmvae667uap4zh", + "product_name": "", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-04-03T13:10:02+00:00", + "create_time": "2024-04-03T13:10:02+00:00", + "update_time": "2024-04-03T13:10:02+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json new file mode 100644 index 00000000000..f4d01c2bc91 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json @@ -0,0 +1,30 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Dehumidifier ", + "category": "cs", + "product_id": "vmxuxszzjwp5smli", + "product_name": "the Smart Dry Plus\u2122 Connect Dehumidifier ", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2024-05-28T01:57:58+00:00", + "create_time": "2024-05-28T01:57:58+00:00", + "update_time": "2024-05-28T01:57:58+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json b/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json new file mode 100644 index 00000000000..fbae30ad3eb --- /dev/null +++ b/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json @@ -0,0 +1,54 @@ +{ + "name": "Dehumidifier", + "category": "cs", + "product_id": "zibqa9dutqyaxym2", + "product_name": "Arete\u00ae Two 12L Dehumidifier/Air Purifier", + "online": true, + "function": { + "switch": { "type": "Boolean", "value": {} }, + "dehumidify_set_value": { + "type": "Integer", + "value": { "unit": "%", "min": 35, "max": 70, "scale": 0, "step": 5 } + }, + "child_lock": { "type": "Boolean", "value": {} }, + "countdown_set": { + "type": "Enum", + "value": { "range": ["cancel", "1h", "2h", "3h"] } + } + }, + "status_range": { + "switch": { "type": "Boolean", "value": {} }, + "dehumidify_set_value": { + "type": "Integer", + "value": { "unit": "%", "min": 35, "max": 70, "scale": 0, "step": 5 } + }, + "child_lock": { "type": "Boolean", "value": {} }, + "humidity_indoor": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "countdown_set": { + "type": "Enum", + "value": { "range": ["cancel", "1h", "2h", "3h"] } + }, + "countdown_left": { + "type": "Integer", + "value": { "unit": "h", "min": 0, "max": 24, "scale": 0, "step": 1 } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["tankfull", "defrost", "E1", "E2", "L2", "L3", "L4", "wet"] + } + } + }, + "status": { + "switch": true, + "dehumidify_set_value": 50, + "child_lock": false, + "humidity_indoor": 47, + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + } +} diff --git a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json new file mode 100644 index 00000000000..a421a69bf08 --- /dev/null +++ b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json @@ -0,0 +1,64 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart Odor Eliminator-Pro", + "category": "cwjwq", + "product_id": "agwu93lr", + "product_name": "Smart Odor Eliminator-Pro", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-25T07:43:07+00:00", + "create_time": "2025-06-25T07:43:07+00:00", + "update_time": "2025-06-25T07:43:07+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + }, + "work_state_e": { + "type": "Enum", + "value": { + "range": ["work", "standby", "charging", "charge_done"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "work_mode": "smart", + "work_state_e": "work", + "battery_percentage": 43 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json new file mode 100644 index 00000000000..e3858d37602 --- /dev/null +++ b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json @@ -0,0 +1,99 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Cleverio PF100", + "category": "cwwsq", + "product_id": "wfkzyy0evslzsmoi", + "product_name": "Cleverio PF100", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-10-20T13:09:34+00:00", + "create_time": "2024-10-20T13:09:34+00:00", + "update_time": "2024-10-20T13:09:34+00:00", + "function": { + "meal_plan": { + "type": "Raw", + "value": {} + }, + "manual_feed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "meal_plan": { + "type": "Raw", + "value": {} + }, + "manual_feed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "charge_state": { + "type": "Boolean", + "value": {} + }, + "feed_report": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "meal_plan": "fwQAAgB/BgABAH8JAAIBfwwAAQB/DwACAX8VAAIBfxcAAQAIEgABAQ==", + "manual_feed": 1, + "factory_reset": false, + "battery_percentage": 90, + "charge_state": false, + "feed_report": 2, + "light": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cwysj_akln8rb04cav403q.json b/tests/components/tuya/fixtures/cwysj_akln8rb04cav403q.json new file mode 100644 index 00000000000..0c13dad643a --- /dev/null +++ b/tests/components/tuya/fixtures/cwysj_akln8rb04cav403q.json @@ -0,0 +1,73 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Water Fountain", + "category": "cwysj", + "product_id": "akln8rb04cav403q", + "product_name": "Smart Pet Water Fountain", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-07-18T11:30:36+00:00", + "create_time": "2025-07-18T11:30:36+00:00", + "update_time": "2025-07-18T11:30:36+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "pump_time": { + "type": "Integer", + "value": { + "unit": "day", + "min": 0, + "max": 31, + "scale": 0, + "step": 1 + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + }, + "filter_life": { + "type": "Integer", + "value": { + "unit": "day", + "min": 0, + "max": 30, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "pump_time": 7, + "filter_reset": false, + "pump_reset": false, + "filter_life": 14 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json new file mode 100644 index 00000000000..6f9a8391726 --- /dev/null +++ b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json @@ -0,0 +1,130 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "PIXI Smart Drinking Fountain", + "category": "cwysj", + "product_id": "z3rpyvznfcch99aa", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-06-17T13:29:17+00:00", + "create_time": "2025-06-17T13:29:17+00:00", + "update_time": "2025-06-17T13:29:17+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_reset": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "uv_runtime": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 10800, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 7200, + "scale": 0, + "step": 1 + } + }, + "filter_life": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "pump_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "water_reset": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "uv_runtime": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 10800, + "scale": 0, + "step": 1 + } + }, + "water_level": { + "type": "Enum", + "value": { + "range": ["level_1", "level_2", "level_3"] + } + } + }, + "status": { + "switch": true, + "water_time": 0, + "filter_life": 18965, + "pump_time": 18965, + "water_reset": false, + "filter_reset": false, + "pump_reset": false, + "uv": false, + "uv_runtime": 0, + "water_level": "level_3" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json b/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json new file mode 100644 index 00000000000..8301c806a71 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Apollo light", + "category": "cz", + "product_id": "0g1fmqh6d5io7lcn", + "product_name": "Mini Smart Plug", + "online": false, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-16T17:19:42+00:00", + "create_time": "2024-06-16T17:19:42+00:00", + "update_time": "2024-06-16T17:19:42+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "秒", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "秒", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_2iepauebcvo74ujc.json b/tests/components/tuya/fixtures/cz_2iepauebcvo74ujc.json new file mode 100644 index 00000000000..e0e41a2ca7e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_2iepauebcvo74ujc.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Aubess Cooker", + "category": "cz", + "product_id": "2iepauebcvo74ujc", + "product_name": "Aubess Smart\u00a0Socket 20A/EM", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-02-03T19:37:03+00:00", + "create_time": "2023-02-03T19:37:03+00:00", + "update_time": "2023-02-03T19:37:03+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 1574, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json new file mode 100644 index 00000000000..c8191f8a023 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "HVAC Meter", + "category": "cz", + "product_id": "2jxesipczks0kdct", + "product_name": "Dual channel metering", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2025-06-19T14:19:08+00:00", + "create_time": "2025-06-19T14:19:08+00:00", + "update_time": "2025-06-19T14:19:08+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "A", + "min": 0, + "max": 80000, + "scale": 3, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "switch_2": true, + "add_ele": 190, + "cur_current": 83, + "cur_power": 64, + "cur_voltage": 1217 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_37mnhia3pojleqfh.json b/tests/components/tuya/fixtures/cz_37mnhia3pojleqfh.json new file mode 100644 index 00000000000..32ba4caf81a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_37mnhia3pojleqfh.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sapphire ", + "category": "cz", + "product_id": "37mnhia3pojleqfh", + "product_name": "SP111", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:54:29+00:00", + "create_time": "2025-03-15T13:54:29+00:00", + "update_time": "2025-03-15T13:54:29+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "s", + "max": 86400, + "step": 1 + } + } + }, + "status_range": { + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "s", + "max": 86400, + "step": 1 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "mA", + "max": 30000, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "min": 0, + "unit": "V", + "scale": 0, + "max": 2500, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "W", + "max": 50000, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "cur_current": 135, + "cur_power": 313, + "cur_voltage": 2357 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_39sy2g68gsjwo2xv.json b/tests/components/tuya/fixtures/cz_39sy2g68gsjwo2xv.json new file mode 100644 index 00000000000..2d067f678f7 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_39sy2g68gsjwo2xv.json @@ -0,0 +1,155 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Ineox SP2", + "category": "cz", + "product_id": "39sy2g68gsjwo2xv", + "product_name": "Ineox SP2", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-03-18T15:08:17+00:00", + "create_time": "2023-03-18T15:08:17+00:00", + "update_time": "2023-03-18T15:08:17+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 3, + "cur_current": 228, + "cur_power": 61, + "cur_voltage": 2321, + "relay_status": "last", + "overcharge_switch": false, + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_6fa7odsufen374x2.json b/tests/components/tuya/fixtures/cz_6fa7odsufen374x2.json new file mode 100644 index 00000000000..0174eb71ca9 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_6fa7odsufen374x2.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Office", + "category": "cz", + "product_id": "6fa7odsufen374x2", + "product_name": "5GHz plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-08-04T14:58:27+00:00", + "create_time": "2025-08-04T14:58:27+00:00", + "update_time": "2025-08-04T14:58:27+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 13, + "cur_current": 253, + "cur_power": 389, + "cur_voltage": 2396, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_9ivirni8wemum6cw.json b/tests/components/tuya/fixtures/cz_9ivirni8wemum6cw.json new file mode 100644 index 00000000000..89643d828cf --- /dev/null +++ b/tests/components/tuya/fixtures/cz_9ivirni8wemum6cw.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gar\u00e1\u017e \u010derpadlo", + "category": "cz", + "product_id": "9ivirni8wemum6cw", + "product_name": "Smart Socket", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-07-18T15:41:56+00:00", + "create_time": "2022-07-18T15:41:56+00:00", + "update_time": "2022-07-18T15:41:56+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2407 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json b/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json new file mode 100644 index 00000000000..2328e901065 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_CHLZe9HQ6QIXujVN.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "schuur", + "category": "cz", + "product_id": "CHLZe9HQ6QIXujVN", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2019-12-02T17:58:38+00:00", + "create_time": "2019-12-02T17:58:38+00:00", + "update_time": "2019-12-02T17:58:38+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json b/tests/components/tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json new file mode 100644 index 00000000000..8a0ede73696 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Rewireable Plug 6930HA", + "model": null, + "category": "cz", + "product_id": "HBRBzv1UVBVfF6SL", + "product_name": "Rewireable Plug 6930HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2021-07-27T08:43:25+00:00", + "create_time": "2021-07-27T08:43:25+00:00", + "update_time": "2022-02-12T10:40:12+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0 + } +} diff --git a/tests/components/tuya/fixtures/cz_anwgf2xugjxpkfxb.json b/tests/components/tuya/fixtures/cz_anwgf2xugjxpkfxb.json new file mode 100644 index 00000000000..4a22e6f59c0 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_anwgf2xugjxpkfxb.json @@ -0,0 +1,116 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Security Light", + "category": "cz", + "product_id": "anwgf2xugjxpkfxb", + "product_name": "Smart Socket ", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2024-04-08T16:20:27+00:00", + "create_time": "2024-04-08T16:20:27+00:00", + "update_time": "2024-04-08T16:20:27+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "relay_status": "power_on", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json b/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json new file mode 100644 index 00000000000..8eaecf2407c --- /dev/null +++ b/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Buitenverlichting", + "category": "cz", + "product_id": "cuhokdii7ojyw8k2", + "product_name": "Smart Plug-EU", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-09-02T12:52:48+00:00", + "create_time": "2021-09-02T12:52:48+00:00", + "update_time": "2021-09-02T12:52:48+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json b/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json new file mode 100644 index 00000000000..77e19d69a0a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json @@ -0,0 +1,33 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "fakkel veranda ", + "category": "cz", + "product_id": "dntgh2ngvshfxpsz", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-18T07:59:28+00:00", + "create_time": "2023-01-18T07:59:28+00:00", + "update_time": "2023-01-18T07:59:28+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json b/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json new file mode 100644 index 00000000000..a244a6c8bcb --- /dev/null +++ b/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "46", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Spa", + "model": "JLCZ8266", + "category": "cz", + "product_id": "fencxse0bnut96ig", + "product_name": "smart plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-28T07:32:38+00:00", + "create_time": "2022-01-28T07:32:38+00:00", + "update_time": "2022-01-28T07:32:52+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 203, + "cur_current": 5404, + "cur_power": 12018, + "cur_voltage": 2381 + } +} diff --git a/tests/components/tuya/fixtures/cz_gbtxrqfy9xcsakyp.json b/tests/components/tuya/fixtures/cz_gbtxrqfy9xcsakyp.json new file mode 100644 index 00000000000..456c2b1fa60 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_gbtxrqfy9xcsakyp.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "3DPrinter", + "category": "cz", + "product_id": "gbtxrqfy9xcsakyp", + "product_name": "Smart Plug+", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-08-16T08:48:56+00:00", + "create_time": "2023-08-16T08:48:56+00:00", + "update_time": "2023-08-16T08:48:56+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2319, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_gjnozsaz.json b/tests/components/tuya/fixtures/cz_gjnozsaz.json new file mode 100644 index 00000000000..dab20faf3c7 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_gjnozsaz.json @@ -0,0 +1,133 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Raspy4 - Home Assistant", + "category": "cz", + "product_id": "gjnozsaz", + "product_name": "Smart plug", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T09:52:30+00:00", + "create_time": "2025-07-19T09:52:30+00:00", + "update_time": "2025-07-19T09:52:30+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 100000000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1500, + "cur_current": 33, + "cur_power": 30, + "cur_voltage": 2440, + "relay_status": "power_on", + "light_mode": "none", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json b/tests/components/tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json new file mode 100644 index 00000000000..d0d7001fc02 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "name": "Spot 4", + "category": "cz", + "product_id": "hA2GsgMfTQFTz9JL", + "product_name": "Mini Smart Socket", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-19T17:17:15+00:00", + "create_time": "2025-06-19T17:17:15+00:00", + "update_time": "2025-06-19T17:17:15+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json b/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json new file mode 100644 index 00000000000..b40297eab8f --- /dev/null +++ b/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json @@ -0,0 +1,98 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "droger", + "category": "cz", + "product_id": "hj0a5c7ckzzexu8l", + "product_name": "Smart plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-02-20T21:27:14+00:00", + "create_time": "2020-02-20T21:27:14+00:00", + "update_time": "2020-02-20T21:27:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 100, + "cur_current": 2754, + "cur_power": 5935, + "cur_voltage": 2224 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_ik9sbig3mthx9hjz.json b/tests/components/tuya/fixtures/cz_ik9sbig3mthx9hjz.json new file mode 100644 index 00000000000..905f4270d18 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_ik9sbig3mthx9hjz.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Aubess Washing Machine", + "category": "cz", + "product_id": "ik9sbig3mthx9hjz", + "product_name": "Aubess Smart\u00a0Socket EM", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-18T10:29:20+00:00", + "create_time": "2024-08-18T10:29:20+00:00", + "update_time": "2024-08-18T10:29:20+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2299, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json b/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json new file mode 100644 index 00000000000..5edf6500132 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json @@ -0,0 +1,165 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "46", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "V\u00e4rmelampa", + "model": "FK-PW802EC-F", + "category": "cz", + "product_id": "ipabufmlmodje1ws", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-22T12:55:50+00:00", + "create_time": "2022-01-21T20:32:42+00:00", + "update_time": "2022-01-22T12:55:53+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 82, + "cur_current": 435, + "cur_power": 1642, + "cur_voltage": 2246, + "test_bit": 1, + "voltage_coe": 632, + "electric_coe": 10795, + "power_coe": 10197, + "electricity_coe": 4090 + } +} diff --git a/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json b/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json new file mode 100644 index 00000000000..958d400eb0e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_iqhidxhhmgxk5eja.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Powerplug 5", + "category": "cz", + "product_id": "iqhidxhhmgxk5eja", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-05-25T11:34:43+00:00", + "create_time": "2020-05-25T11:34:43+00:00", + "update_time": "2020-05-25T11:34:43+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_jnbbxsb84gvvyfg5.json b/tests/components/tuya/fixtures/cz_jnbbxsb84gvvyfg5.json new file mode 100644 index 00000000000..03edf52d3c4 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_jnbbxsb84gvvyfg5.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom Fan", + "model": "6920HA", + "category": "cz", + "product_id": "jnbbxsb84gvvyfg5", + "product_name": "Plug Base 6210HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2021-08-23T20:40:36+00:00", + "create_time": "2021-08-18T13:14:59+00:00", + "update_time": "2022-02-12T10:40:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + } +} diff --git a/tests/components/tuya/fixtures/cz_n8iVBAPLFKAAAszH.json b/tests/components/tuya/fixtures/cz_n8iVBAPLFKAAAszH.json new file mode 100644 index 00000000000..4de85bb849f --- /dev/null +++ b/tests/components/tuya/fixtures/cz_n8iVBAPLFKAAAszH.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Steckdose 2", + "category": "cz", + "product_id": "n8iVBAPLFKAAAszH", + "product_name": "Socket", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:17:22+00:00", + "create_time": "2025-03-15T13:17:22+00:00", + "update_time": "2025-03-15T13:17:22+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_nkb0fmtlfyqosnvk.json b/tests/components/tuya/fixtures/cz_nkb0fmtlfyqosnvk.json new file mode 100644 index 00000000000..f0fe165ecb8 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_nkb0fmtlfyqosnvk.json @@ -0,0 +1,98 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bassin", + "category": "cz", + "product_id": "nkb0fmtlfyqosnvk", + "product_name": "Konyks Pluviose Easy EU", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-04-11T16:27:36+00:00", + "create_time": "2024-04-11T16:27:36+00:00", + "update_time": "2024-04-11T16:27:36+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 21, + "cur_current": 783, + "cur_power": 411, + "cur_voltage": 2454 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_nx8rv6jpe1tsnffk.json b/tests/components/tuya/fixtures/cz_nx8rv6jpe1tsnffk.json new file mode 100644 index 00000000000..277a2fbe81e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_nx8rv6jpe1tsnffk.json @@ -0,0 +1,116 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "name": "Spot 1", + "category": "cz", + "product_id": "nx8rv6jpe1tsnffk", + "product_name": "Smart plug", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-21T17:03:23+00:00", + "create_time": "2025-06-21T17:03:23+00:00", + "update_time": "2025-06-21T17:03:23+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json b/tests/components/tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json new file mode 100644 index 00000000000..167878abc59 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Elivco Kitchen Socket", + "category": "cz", + "product_id": "qm0iq4nqnrlzh4qc", + "product_name": "Smart plug", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-03-29T15:03:22+00:00", + "create_time": "2023-03-29T15:03:22+00:00", + "update_time": "2023-03-29T15:03:22+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 24, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2334, + "relay_status": "power_on", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_raceucn29wk2yawe.json b/tests/components/tuya/fixtures/cz_raceucn29wk2yawe.json new file mode 100644 index 00000000000..a77bfd79d6e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_raceucn29wk2yawe.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom Mirror", + "model": "", + "category": "cz", + "product_id": "raceucn29wk2yawe", + "product_name": "Inline Switch 6000HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2021-08-18T13:03:59+00:00", + "create_time": "2021-08-18T13:03:59+00:00", + "update_time": "2022-02-12T10:40:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0 + } +} diff --git a/tests/components/tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json b/tests/components/tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json new file mode 100644 index 00000000000..b077af094fa --- /dev/null +++ b/tests/components/tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Socket4", + "category": "cz", + "product_id": "sb6bwb1n8ma2c5q4", + "product_name": "WIFI \u63d2\u5ea7", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-16T18:50:14+00:00", + "create_time": "2025-01-16T18:50:14+00:00", + "update_time": "2025-01-16T18:50:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 0, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2325, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json b/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json new file mode 100644 index 00000000000..04a2d12e853 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json @@ -0,0 +1,116 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "wallwasher front", + "category": "cz", + "product_id": "t0a4hwsf8anfsadp", + "product_name": "Smart Plug ", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-12-11T15:51:53+00:00", + "create_time": "2022-12-11T15:51:53+00:00", + "update_time": "2022-12-11T15:51:53+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "relay_status": "power_on", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_tf6qp8t3hl9h7m94.json b/tests/components/tuya/fixtures/cz_tf6qp8t3hl9h7m94.json new file mode 100644 index 00000000000..22db23d06dc --- /dev/null +++ b/tests/components/tuya/fixtures/cz_tf6qp8t3hl9h7m94.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Consommation", + "category": "cz", + "product_id": "tf6qp8t3hl9h7m94", + "product_name": "smart meter with CT-2", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-18T08:10:00+00:00", + "create_time": "2025-04-18T08:10:00+00:00", + "update_time": "2025-04-18T08:10:00+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "A", + "min": 0, + "max": 80000, + "scale": 3, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "switch_2": false, + "add_ele": 100, + "cur_current": 2585, + "cur_power": 4258, + "cur_voltage": 2416 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_tkn2s79mzedk6pwr.json b/tests/components/tuya/fixtures/cz_tkn2s79mzedk6pwr.json new file mode 100644 index 00000000000..4a551736c3f --- /dev/null +++ b/tests/components/tuya/fixtures/cz_tkn2s79mzedk6pwr.json @@ -0,0 +1,160 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Weihnachtsmann ", + "category": "cz", + "product_id": "tkn2s79mzedk6pwr", + "product_name": "Smart Socket ", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-11-28T18:55:54+00:00", + "create_time": "2023-11-28T18:55:54+00:00", + "update_time": "2023-11-28T18:55:54+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 80000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 4, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_vxqn72kwtosoy4d3.json b/tests/components/tuya/fixtures/cz_vxqn72kwtosoy4d3.json new file mode 100644 index 00000000000..3b4b98514ba --- /dev/null +++ b/tests/components/tuya/fixtures/cz_vxqn72kwtosoy4d3.json @@ -0,0 +1,98 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage Socket", + "category": "cz", + "product_id": "vxqn72kwtosoy4d3", + "product_name": "Smart Plug+", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-11T13:38:52+00:00", + "create_time": "2024-01-11T13:38:52+00:00", + "update_time": "2024-01-11T13:38:52+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 80000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 2, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2350 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_w0qqde0g.json b/tests/components/tuya/fixtures/cz_w0qqde0g.json new file mode 100644 index 00000000000..6d960603ba1 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_w0qqde0g.json @@ -0,0 +1,133 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lave linge", + "category": "cz", + "product_id": "w0qqde0g", + "product_name": "Smart plug", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T08:38:51+00:00", + "create_time": "2025-07-19T08:38:51+00:00", + "update_time": "2025-07-19T08:38:51+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 100000000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 62860, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2440, + "relay_status": "power_on", + "light_mode": "none", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_wifvoilfrqeo6hvu.json b/tests/components/tuya/fixtures/cz_wifvoilfrqeo6hvu.json new file mode 100644 index 00000000000..e0912445003 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_wifvoilfrqeo6hvu.json @@ -0,0 +1,98 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Licht drucker", + "category": "cz", + "product_id": "wifvoilfrqeo6hvu", + "product_name": "Smart Socket", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:48:41+00:00", + "create_time": "2025-03-15T13:48:41+00:00", + "update_time": "2025-03-15T13:48:41+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "\u79d2", + "max": 86400, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "\u79d2", + "max": 86400, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "\u5ea6", + "max": 500000, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "mA", + "max": 30000, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "W", + "max": 50000, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "min": 0, + "unit": "V", + "scale": 0, + "max": 2500, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 10, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2346 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_wrz6vzch8htux2zp.json b/tests/components/tuya/fixtures/cz_wrz6vzch8htux2zp.json new file mode 100644 index 00000000000..29cb9488745 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_wrz6vzch8htux2zp.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Elivco TV", + "category": "cz", + "product_id": "wrz6vzch8htux2zp", + "product_name": "WiFi Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-02-20T12:10:18+00:00", + "create_time": "2023-02-20T12:10:18+00:00", + "update_time": "2023-02-20T12:10:18+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 6, + "cur_current": 91, + "cur_power": 100, + "cur_voltage": 2377, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_y4jnobxh.json b/tests/components/tuya/fixtures/cz_y4jnobxh.json new file mode 100644 index 00000000000..27680e4521a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_y4jnobxh.json @@ -0,0 +1,33 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "AuVeLiCo", + "category": "cz", + "product_id": "y4jnobxh", + "product_name": "\u3010\u901a\u7528\u63a5\u5165\u30111\u8def\u63d2\u5ea7", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T09:30:43+00:00", + "create_time": "2025-07-19T09:30:43+00:00", + "update_time": "2025-07-19T09:30:43+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json b/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json new file mode 100644 index 00000000000..01caa14439a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json @@ -0,0 +1,207 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "tuyaSmart", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "6294HA", + "model": "", + "category": "cz", + "product_id": "z6pht25s3p0gs26q", + "product_name": "6294HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-20T11:03:22+00:00", + "create_time": "2022-01-10T01:30:10+00:00", + "update_time": "2022-01-20T11:03:22+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "switch_2": false, + "countdown_1": 0, + "countdown_2": 0, + "add_ele": 201, + "cur_current": 5466, + "cur_power": 11374, + "cur_voltage": 2396, + "test_bit": 2, + "voltage_coe": 0, + "electric_coe": 0, + "power_coe": 0, + "electricity_coe": 0, + "relay_status": "power_on", + "child_lock": false + } +} diff --git a/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json b/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json new file mode 100644 index 00000000000..198a2462ad1 --- /dev/null +++ b/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json @@ -0,0 +1,147 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "LSC Party String Light RGBIC+CCT ", + "category": "dc", + "product_id": "l3bpgg8ibsagon4x", + "product_name": "LSC Party String Light RGBIC+CCT ", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-07-18T20:38:14+00:00", + "create_time": "2024-07-18T20:38:14+00:00", + "update_time": "2024-07-18T20:38:14+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "music_data": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value": 1000, + "temp_value": 0, + "colour_data": { + "h": 229, + "s": 1000, + "v": 1000 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json b/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json new file mode 100644 index 00000000000..b0135acba1c --- /dev/null +++ b/tests/components/tuya/fixtures/dd_gaobbrxqiblcng2p.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "TV Sync Backlights", + "category": "dd", + "product_id": "gaobbrxqiblcng2p", + "product_name": "TV Sync Backlights", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-31T10:40:08+00:00", + "create_time": "2024-08-31T10:40:08+00:00", + "update_time": "2024-08-31T10:40:08+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_0gyaslysqfp4gfis.json b/tests/components/tuya/fixtures/dj_0gyaslysqfp4gfis.json new file mode 100644 index 00000000000..3ab0f17cb9d --- /dev/null +++ b/tests/components/tuya/fixtures/dj_0gyaslysqfp4gfis.json @@ -0,0 +1,557 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Study 1", + "category": "dj", + "product_id": "0gyaslysqfp4gfis", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2025-06-29T07:45:01+00:00", + "create_time": "2025-06-29T07:45:01+00:00", + "update_time": "2025-06-29T07:45:01+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAAAPoA+gD6APo", + "do_not_disturb": true, + "remote_switch": true, + "cycle_timing": "AAAA", + "random_timing": "AAAA" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json b/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json new file mode 100644 index 00000000000..8b6e491fa43 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json @@ -0,0 +1,493 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Porch light E", + "category": "dj", + "product_id": "8szt7whdvwpmxglk", + "product_name": "Smart Light Bulb", + "online": true, + "sub": false, + "time_zone": "-06:00", + "active_time": "2024-06-19T00:38:29+00:00", + "create_time": "2024-06-19T00:38:29+00:00", + "update_time": "2024-06-19T00:38:29+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "colour_data_v2": { + "h": 245, + "s": 780, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json b/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json new file mode 100644 index 00000000000..d2e36e71f49 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json @@ -0,0 +1,336 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "dressoir spot", + "category": "dj", + "product_id": "8y0aquaa8v6tho8w", + "product_name": "A60 Clear", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-18T07:49:40+00:00", + "create_time": "2023-01-18T07:49:40+00:00", + "update_time": "2023-01-18T07:49:40+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "remote_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json b/tests/components/tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json new file mode 100644 index 00000000000..8e54b45ee68 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json @@ -0,0 +1,508 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Landing", + "category": "dj", + "product_id": "AqHUMdcbYzIq1Of4", + "product_name": "Smart Bulb", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-04-28T06:04:01+00:00", + "create_time": "2023-04-28T06:04:01+00:00", + "update_time": "2023-04-28T06:04:01+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value": 255, + "temp_value": 127, + "colour_data": { + "h": 1.0, + "s": 1.0, + "v": 255.0 + }, + "scene_data": { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + "flash_scene_1": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_2": { + "bright": 255, + "frequency": 128, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + }, + "flash_scene_3": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_4": { + "bright": 255, + "frequency": 5, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 60.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 300.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_amx1bgdrfab6jngb.json b/tests/components/tuya/fixtures/dj_amx1bgdrfab6jngb.json new file mode 100644 index 00000000000..1978a729b1a --- /dev/null +++ b/tests/components/tuya/fixtures/dj_amx1bgdrfab6jngb.json @@ -0,0 +1,333 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lumy Hall", + "category": "dj", + "product_id": "amx1bgdrfab6jngb", + "product_name": "A60 Clear", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-05-20T16:20:25+00:00", + "create_time": "2024-05-20T16:20:25+00:00", + "update_time": "2024-05-20T16:20:25+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_bSXSSFArVKtc4DyC.json b/tests/components/tuya/fixtures/dj_bSXSSFArVKtc4DyC.json new file mode 100644 index 00000000000..a0e9027e70c --- /dev/null +++ b/tests/components/tuya/fixtures/dj_bSXSSFArVKtc4DyC.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "bedroom", + "category": "dj", + "product_id": "bSXSSFArVKtc4DyC", + "product_name": "Dimmer switch", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-04-28T05:43:06+00:00", + "create_time": "2023-04-28T05:43:06+00:00", + "update_time": "2023-04-28T05:43:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 11 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json b/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json new file mode 100644 index 00000000000..86d1f8fd9d5 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json @@ -0,0 +1,75 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Pokerlamp 2", + "category": "dj", + "product_id": "baf9tt9lb8t5uc7z", + "product_name": "LED SMART", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-10-30T17:22:29+00:00", + "create_time": "2021-10-30T17:22:29+00:00", + "update_time": "2021-10-30T17:22:29+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": true, + "bright_value": 45, + "temp_value": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_c3nsqogqovapdpfj.json b/tests/components/tuya/fixtures/dj_c3nsqogqovapdpfj.json new file mode 100644 index 00000000000..c5a1aefec54 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_c3nsqogqovapdpfj.json @@ -0,0 +1,348 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Arbeitszimmer led", + "category": "dj", + "product_id": "c3nsqogqovapdpfj", + "product_name": "RGBstriplight", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:33:52+00:00", + "create_time": "2025-03-15T13:33:52+00:00", + "update_time": "2025-03-15T13:33:52+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "scene", + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 2, + "scene_units": [ + { + "bright": 0, + "h": 132, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json b/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json new file mode 100644 index 00000000000..024501d59de --- /dev/null +++ b/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json @@ -0,0 +1,375 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "WC D1", + "category": "dj", + "product_id": "d4g0fbsoaal841o6", + "product_name": "A60 GOLD", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-30T11:36:31+00:00", + "create_time": "2021-06-30T11:36:31+00:00", + "update_time": "2021-06-30T11:36:31+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_dbou1ap4.json b/tests/components/tuya/fixtures/dj_dbou1ap4.json new file mode 100644 index 00000000000..86f16136678 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_dbou1ap4.json @@ -0,0 +1,390 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lumy Garage", + "category": "dj", + "product_id": "dbou1ap4", + "product_name": "atmosphere", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:19:47+00:00", + "create_time": "2025-07-19T11:19:47+00:00", + "update_time": "2025-07-19T11:19:47+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 186, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 13, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json b/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json new file mode 100644 index 00000000000..d48e7228566 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json @@ -0,0 +1,482 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Fakkel 8", + "category": "dj", + "product_id": "djnozmdyqyriow8z", + "product_name": "Candle RGB-CCT", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-30T12:13:49+00:00", + "create_time": "2021-06-30T12:13:49+00:00", + "update_time": "2021-06-30T12:13:49+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 280, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 56, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 8, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json b/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json new file mode 100644 index 00000000000..ae3a53e606e --- /dev/null +++ b/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json @@ -0,0 +1,557 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "ab6", + "category": "dj", + "product_id": "ekwolitfjhxn55js", + "product_name": "LSC Smart Connect GU10 RGB+CCT", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-10-30T21:26:33+00:00", + "create_time": "2024-10-30T21:26:33+00:00", + "update_time": "2024-10-30T21:26:33+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 3, + "s": 994, + "v": 443 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAAAPoA+gD6AAA", + "do_not_disturb": false, + "remote_switch": true, + "cycle_timing": "AAAA", + "random_timing": "AAAA" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json b/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json new file mode 100644 index 00000000000..39cb6b78460 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json @@ -0,0 +1,336 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Slaapkamer", + "category": "dj", + "product_id": "fuupmcr2mb1odkja", + "product_name": "ST64 Clear", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-28T01:25:04+00:00", + "create_time": "2023-01-28T01:25:04+00:00", + "update_time": "2023-01-28T01:25:04+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json b/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json new file mode 100644 index 00000000000..22e5eee1b6f --- /dev/null +++ b/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json @@ -0,0 +1,508 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Master bedroom TV lights", + "category": "dj", + "product_id": "hp6orhaqm6as3jnv", + "product_name": "LED Strip Lights", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-19T03:35:54+00:00", + "create_time": "2024-06-19T03:35:54+00:00", + "update_time": "2024-06-19T03:35:54+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value": 96, + "temp_value": 223, + "colour_data": { + "h": 27.0, + "s": 255.0, + "v": 52.0 + }, + "scene_data": { + "h": 16.0, + "s": 255.0, + "v": 210.9 + }, + "flash_scene_1": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_2": { + "bright": 255, + "frequency": 128, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + }, + "flash_scene_3": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_4": { + "bright": 255, + "frequency": 5, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 60.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 300.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json b/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json new file mode 100644 index 00000000000..b7190caa78e --- /dev/null +++ b/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json @@ -0,0 +1,154 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage", + "category": "dj", + "product_id": "hpc8ddyfv85haxa7", + "product_name": "RGB Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-12-21T14:43:57+00:00", + "create_time": "2020-12-21T14:43:57+00:00", + "update_time": "2020-12-21T14:43:57+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value": 255, + "temp_value": 255, + "colour_data_v2": { + "h": 16384, + "s": 65280, + "v": 65535 + }, + "switch_1": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json b/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json new file mode 100644 index 00000000000..a8cddb4ee4f --- /dev/null +++ b/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json @@ -0,0 +1,527 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "LED Porch 2", + "category": "dj", + "product_id": "iayz2jmtlipjnxj7", + "product_name": "LED Strip RGB+W", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-07T10:55:19+00:00", + "create_time": "2021-06-07T10:55:19+00:00", + "update_time": "2021-06-07T10:55:19+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 839, + "colour_data_v2": { + "h": 13, + "s": 992, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 8, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json b/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json new file mode 100644 index 00000000000..299e8d573f1 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json @@ -0,0 +1,521 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "AB1", + "category": "dj", + "product_id": "idnfq7xbx8qewyoa", + "product_name": "Smart Lamp", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-08-16T12:51:52+00:00", + "create_time": "2021-08-16T12:51:52+00:00", + "update_time": "2021-08-16T12:51:52+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "scene", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 6, + "s": 978, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 6, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json b/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json new file mode 100644 index 00000000000..affa875f3b4 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json @@ -0,0 +1,75 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Ieskas", + "category": "dj", + "product_id": "ilddqqih3tucdk68", + "product_name": "LED SMART", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-28T20:07:13+00:00", + "create_time": "2025-05-28T20:07:13+00:00", + "update_time": "2025-05-28T20:07:13+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": true, + "bright_value": 255, + "temp_value": 158 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json b/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json new file mode 100644 index 00000000000..01c7e375002 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json @@ -0,0 +1,432 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Ceiling Portal", + "category": "dj", + "product_id": "j1bgp31cffutizub", + "product_name": "LSC Smart Ceiling Light", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-31T12:27:35+00:00", + "create_time": "2022-01-31T12:27:35+00:00", + "update_time": "2022-01-31T12:27:35+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 950, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json b/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json new file mode 100644 index 00000000000..54c08ba7762 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json @@ -0,0 +1,75 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "DirectietKamer", + "category": "dj", + "product_id": "lmnt3uyltk1xffrt", + "product_name": "LED SMART", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-28T20:00:48+00:00", + "create_time": "2025-05-28T20:00:48+00:00", + "update_time": "2025-05-28T20:00:48+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 255, + "temp_value": 255 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json b/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json new file mode 100644 index 00000000000..daea124e8e0 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json @@ -0,0 +1,456 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage light", + "category": "dj", + "product_id": "mki13ie507rlry4r", + "product_name": "Smart Light Bulb", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-15T19:53:11+00:00", + "create_time": "2024-06-15T19:53:11+00:00", + "update_time": "2024-06-15T19:53:11+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 546, + "colour_data_v2": { + "h": 243, + "s": 860, + "v": 541 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json b/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json new file mode 100644 index 00000000000..3cac3935c27 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json @@ -0,0 +1,557 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "b2", + "category": "dj", + "product_id": "nbumqpv8vz61enji", + "product_name": "LSC smart GU10", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-10-30T21:35:27+00:00", + "create_time": "2024-10-30T21:35:27+00:00", + "update_time": "2024-10-30T21:35:27+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 10, + "temp_value_v2": 150, + "colour_data_v2": { + "h": 119, + "s": 935, + "v": 132 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAAAPoA+gD6ACW", + "do_not_disturb": true, + "remote_switch": true, + "cycle_timing": "AAAA", + "random_timing": "AAAA" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json b/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json new file mode 100644 index 00000000000..5fbea6fb287 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json @@ -0,0 +1,75 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "hall 💡 ", + "category": "dj", + "product_id": "nlxvjzy1hoeiqsg6", + "product_name": "LED SMART", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-06-23T21:37:40+00:00", + "create_time": "2020-06-23T21:37:40+00:00", + "update_time": "2020-06-23T21:37:40+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 135, + "temp_value": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_oe0cpnjg.json b/tests/components/tuya/fixtures/dj_oe0cpnjg.json new file mode 100644 index 00000000000..8c2a559a5c9 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_oe0cpnjg.json @@ -0,0 +1,224 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Front right Lighting trap", + "category": "dj", + "product_id": "oe0cpnjg", + "product_name": "Smart Lighting", + "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-10-03T13:23:20+00:00", + "create_time": "2023-10-03T13:23:20+00:00", + "update_time": "2023-10-03T13:23:20+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 985, + "colour_data_v2": "", + "music_data": "" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json b/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json new file mode 100644 index 00000000000..e623ac6f7c0 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_qoqolwtqzfuhgghq.json @@ -0,0 +1,477 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart Bulb RGBCW", + "category": "dj", + "product_id": "qoqolwtqzfuhgghq", + "product_name": "Smart Bulb RGBCW", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-04T09:40:06+00:00", + "create_time": "2022-01-04T09:40:06+00:00", + "update_time": "2022-01-04T09:40:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 435, + "colour_data_v2": { + "h": 35, + "s": 760, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 1000, + "h": 0, + "s": 0, + "temperature": 85, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_riwp3k79.json b/tests/components/tuya/fixtures/dj_riwp3k79.json new file mode 100644 index 00000000000..bd4d013ab5b --- /dev/null +++ b/tests/components/tuya/fixtures/dj_riwp3k79.json @@ -0,0 +1,400 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "LED KEUKEN 2", + "category": "dj", + "product_id": "riwp3k79", + "product_name": "atmosphere", + "online": true, + "sub": true, + "time_zone": "+08:00", + "active_time": "2020-12-29T16:16:11+00:00", + "create_time": "2020-12-29T16:16:11+00:00", + "update_time": "2020-12-29T16:16:11+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 27, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 6, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 100, + "unit_switch_duration": 100, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 100, + "unit_switch_duration": 100, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_tgewj70aowigv8fz.json b/tests/components/tuya/fixtures/dj_tgewj70aowigv8fz.json new file mode 100644 index 00000000000..d02a94e0f71 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_tgewj70aowigv8fz.json @@ -0,0 +1,140 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Stairs", + "category": "dj", + "product_id": "tgewj70aowigv8fz", + "product_name": "RGBC Smart Bulb", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-04-28T07:01:03+00:00", + "create_time": "2023-04-28T07:01:03+00:00", + "update_time": "2023-04-28T07:01:03+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "white", + "colour", + "scene", + "scene_1", + "scene_2", + "scene_3", + "scene_4" + ] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "white", + "colour", + "scene", + "scene_1", + "scene_2", + "scene_3", + "scene_4" + ] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value": 71, + "colour_data": { + "h": 0.0, + "s": 0.0, + "v": 255.0 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json b/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json new file mode 100644 index 00000000000..91c4dff5a42 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json @@ -0,0 +1,375 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Pokerlamp 1", + "category": "dj", + "product_id": "tmsloaroqavbucgn", + "product_name": "G95-Filament", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-29T16:12:54+00:00", + "create_time": "2021-06-29T16:12:54+00:00", + "update_time": "2021-06-29T16:12:54+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 400, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json b/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json new file mode 100644 index 00000000000..4b7a3a4e879 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json @@ -0,0 +1,333 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sjiethoes", + "category": "dj", + "product_id": "ufq2xwuzd4nb0qdr", + "product_name": "Smart Ceiling Lamp", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-29T09:40:53+00:00", + "create_time": "2025-04-29T09:40:53+00:00", + "update_time": "2025-04-29T09:40:53+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 46, + "s": 1000, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json b/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json new file mode 100644 index 00000000000..9aa3646a11b --- /dev/null +++ b/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json @@ -0,0 +1,530 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Strip 2", + "category": "dj", + "product_id": "vqwcnabamzrc2kab", + "product_name": "Light Strip-RGBCW ", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-10-22T13:55:55+00:00", + "create_time": "2021-10-22T13:55:55+00:00", + "update_time": "2021-10-22T13:55:55+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 218, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 8, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_xdvitmhhmgefaeuq.json b/tests/components/tuya/fixtures/dj_xdvitmhhmgefaeuq.json new file mode 100644 index 00000000000..32688d06f5a --- /dev/null +++ b/tests/components/tuya/fixtures/dj_xdvitmhhmgefaeuq.json @@ -0,0 +1,510 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "druckerhell", + "category": "dj", + "product_id": "xdvitmhhmgefaeuq", + "product_name": "GU10 Smart Bulb", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:02:08+00:00", + "create_time": "2025-03-15T13:02:08+00:00", + "update_time": "2025-03-15T13:02:08+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json b/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json new file mode 100644 index 00000000000..2e339c64678 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json @@ -0,0 +1,375 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "ERKER 1-Gold ", + "category": "dj", + "product_id": "xokdfs6kh5ednakk", + "product_name": "LSC-G125-Gold ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-30T22:02:31+00:00", + "create_time": "2022-01-30T22:02:31+00:00", + "update_time": "2022-01-30T22:02:31+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json b/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json new file mode 100644 index 00000000000..2a6b4f34ce7 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json @@ -0,0 +1,75 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Stoel", + "category": "dj", + "product_id": "zakhnlpdiu0ycdxn", + "product_name": "LED SMART", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-08-10T18:55:12+00:00", + "create_time": "2023-08-10T18:55:12+00:00", + "update_time": "2023-08-10T18:55:12+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 71, + "temp_value": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json b/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json new file mode 100644 index 00000000000..0ae793b3d1b --- /dev/null +++ b/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json @@ -0,0 +1,320 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gengske 💡 ", + "category": "dj", + "product_id": "zav1pa32pyxray78", + "product_name": "Ceiling Light RGBTW", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-28T20:00:48+00:00", + "create_time": "2025-05-28T20:00:48+00:00", + "update_time": "2025-05-28T20:00:48+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 380, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 102 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json b/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json new file mode 100644 index 00000000000..b500c67d0ea --- /dev/null +++ b/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json @@ -0,0 +1,411 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Floodlight", + "category": "dj", + "product_id": "zputiamzanuk6yky", + "product_name": "LSC Floodlight", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-09T08:14:06+00:00", + "create_time": "2025-06-09T08:14:06+00:00", + "update_time": "2025-06-09T08:14:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "colour_data_v2": { + "h": 295, + "s": 920, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAGwG/A+gD6APo", + "do_not_disturb": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_0tnvg2xaisqdadcf.json b/tests/components/tuya/fixtures/dlq_0tnvg2xaisqdadcf.json new file mode 100644 index 00000000000..32535964a7e --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_0tnvg2xaisqdadcf.json @@ -0,0 +1,247 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "48", + "app_type": "tuyaSmart", + "mqtt_connected": null, + "disabled_by": null, + "disabled_polling": false, + "name": "\u4e00\u8def\u5e26\u8ba1\u91cf\u78c1\u4fdd\u6301\u901a\u65ad\u5668", + "model": "", + "category": "dlq", + "product_id": "0tnvg2xaisqdadcf", + "product_name": "\u4e00\u8def\u5e26\u8ba1\u91cf\u78c1\u4fdd\u6301\u901a\u65ad\u5668", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-11-25T21:50:37+00:00", + "create_time": "2023-11-25T21:49:06+00:00", + "update_time": "2023-11-28T16:32:28+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 99999, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "ov_cr", + "ov_vol", + "ov_pwr", + "ls_cr", + "ls_vol", + "ls_pow", + "short_circuit_alarm", + "overload_alarm", + "leakagecurr_alarm", + "self_test_alarm", + "high_temp", + "unbalance_alarm", + "miss_phase_alarm" + ] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "add_ele": 100, + "cur_current": 2198, + "cur_power": 4953, + "cur_voltage": 2314, + "test_bit": 2, + "voltage_coe": 0, + "electric_coe": 0, + "power_coe": 0, + "electricity_coe": 0, + "fault": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "temp_value": 0, + "alarm_set_1": "", + "alarm_set_2": "AQAAAAMAAAAEAAAA" + } +} diff --git a/tests/components/tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json b/tests/components/tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json new file mode 100644 index 00000000000..aa42fc0f568 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_cnpkf4xdmd9v49iq.json @@ -0,0 +1,158 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u65ad\u8def\u5668HA", + "category": "dlq", + "product_id": "cnpkf4xdmd9v49iq", + "product_name": "Breaker", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-07-03T10:19:11+00:00", + "create_time": "2025-07-03T10:19:11+00:00", + "update_time": "2025-07-03T10:19:11+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "leakagecurr_test": { + "type": "Boolean", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 3, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm", + "leakage_early_warning", + "overcur_early_warning", + "overvol_early_warning", + "overpow_early_warning", + "undvol_early_warning", + "higtemp_early_warning" + ] + } + }, + "leakage_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "leakagecurr_test": { + "type": "Boolean", + "value": {} + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "forward_energy_total": 120, + "phase_a": "Ag8JJQAASAAACAAAAAAACGME", + "fault": 0, + "leakage_current": 0, + "switch": false, + "alarm_set_1": "", + "alarm_set_2": "", + "breaker_number": "", + "leakagecurr_test": false, + "supply_frequency": 0, + "online_state": "online", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_jdj6ccklup7btq3a.json b/tests/components/tuya/fixtures/dlq_jdj6ccklup7btq3a.json new file mode 100644 index 00000000000..0b8bceb73e3 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_jdj6ccklup7btq3a.json @@ -0,0 +1,216 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Eau Chaude", + "category": "dlq", + "product_id": "jdj6ccklup7btq3a", + "product_name": "WiFi Din Rail Switch with metering", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-14T12:13:35+00:00", + "create_time": "2025-04-14T12:13:35+00:00", + "update_time": "2025-04-14T12:13:35+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 9999999, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 999999, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "add_ele": 390, + "cur_current": 10067, + "cur_power": 24417, + "cur_voltage": 2419, + "test_bit": 1, + "voltage_coe": 15943, + "electric_coe": 12577, + "power_coe": 3125, + "electricity_coe": 2682, + "fault": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "online_state": "online" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json new file mode 100644 index 00000000000..eaec5aed56c --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json @@ -0,0 +1,135 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": null, + "disabled_by": null, + "disabled_polling": false, + "name": "Metering_3PN_WiFi_stable", + "category": "dlq", + "product_id": "kxdr6su0c55p7bbo", + "product_name": "Metering_3PN_WiFi", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2024-12-08T17:37:45+00:00", + "create_time": "2024-12-08T17:37:45+00:00", + "update_time": "2024-12-08T17:37:45+00:00", + "function": { + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW.h", + "min": 0, + "max": 999999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm" + ] + } + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status": { + "forward_energy_total": 2435416, + "phase_a": "CKMAAn0AAGw=", + "phase_b": "CIsAK8MACWo=", + "phase_c": "CJwAA5EAAFw=", + "fault": 0, + "energy_reset": "", + "alarm_set_1": "BwEADQ==", + "alarm_set_2": "AQEAPAMBAP0EAQC0BQEAAAcBAAAIAQAeCQAAAA==", + "breaker_number": "SPM02_6588", + "supply_frequency": 5000, + "online_state": "online" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_r9kg2g1uhhyicycb.json b/tests/components/tuya/fixtures/dlq_r9kg2g1uhhyicycb.json new file mode 100644 index 00000000000..3ebbb27b349 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_r9kg2g1uhhyicycb.json @@ -0,0 +1,143 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": false, + "disabled_by": null, + "disabled_polling": false, + "name": "P1 Energia Elettrica", + "category": "dlq", + "product_id": "r9kg2g1uhhyicycb", + "product_name": "Breaker ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-12-10T17:33:13+00:00", + "create_time": "2022-12-10T17:33:13+00:00", + "update_time": "2022-12-10T17:33:13+00:00", + "function": { + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "total_forward_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm" + ] + } + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "total_forward_energy": 2279960, + "phase_a": "CGYAPCgADPIACw==", + "phase_b": "AAAAAAAAAAAAAA==", + "phase_c": "AAAAAAAAAAAAAA==", + "fault": 0, + "switch_prepayment": false, + "energy_reset": "", + "balance_energy": 0, + "charge_energy": 0, + "switch": true, + "breaker_number": "FSE-F723C5EA0AC8B6" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_z3jngbyubvwgfrcv.json b/tests/components/tuya/fixtures/dlq_z3jngbyubvwgfrcv.json new file mode 100644 index 00000000000..695b8a35414 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_z3jngbyubvwgfrcv.json @@ -0,0 +1,222 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Edesanya Energy", + "category": "dlq", + "product_id": "z3jngbyubvwgfrcv", + "product_name": "Breaker", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2025-06-16T11:30:16+00:00", + "create_time": "2025-06-16T11:30:16+00:00", + "update_time": "2025-06-16T11:30:16+00:00", + "function": { + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "random_time": { + "type": "String", + "value": {} + } + }, + "status_range": { + "total_forward_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm", + "phase_seq_err_alarm", + "vol_unbalance_alarm", + "low_current_alarm" + ] + } + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "leakage_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -20, + "max": 200, + "scale": 0, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "random_time": { + "type": "String", + "value": {} + } + }, + "status": { + "total_forward_energy": 21972, + "phase_a": "CT0AAmAAAIU=", + "phase_b": "", + "phase_c": "", + "fault": 0, + "switch_prepayment": false, + "energy_reset": "", + "balance_energy": 0, + "charge_energy": 0, + "leakage_current": 0, + "switch": true, + "alarm_set_1": "BAEAMgUBAFA=", + "alarm_set_2": "AQECdgMBARMEAQCv", + "temp_current": 24, + "countdown_1": 0, + "cycle_time": "EwAAAAAAAAAAAA==", + "random_time": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dr_pjvxl1wsyqxivsaf.json b/tests/components/tuya/fixtures/dr_pjvxl1wsyqxivsaf.json new file mode 100644 index 00000000000..78fce362d37 --- /dev/null +++ b/tests/components/tuya/fixtures/dr_pjvxl1wsyqxivsaf.json @@ -0,0 +1,185 @@ +{ + "endpoint": "https://openapi.tuyaus.com", + "auth_type": 0, + "country_code": "1", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sunbeam Bedding", + "model": "", + "category": "dr", + "product_id": "pjvxl1wsyqxivsaf", + "product_name": "Sunbeam Bedding", + "online": true, + "sub": false, + "time_zone": "-05:00", + "active_time": "2022-11-07T00:20:52+00:00", + "create_time": "2022-11-01T00:43:45+00:00", + "update_time": "2022-11-07T00:20:52+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "level": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "preheat": { + "type": "Boolean", + "value": {} + }, + "preheat_1": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "preheat_2": { + "type": "Boolean", + "value": {} + }, + "level_1": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "level_2": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + } + }, + "status_range": { + "level_1": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "level_2": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "level": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "preheat": { + "type": "Boolean", + "value": {} + }, + "preheat_2": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "preheat_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "level": "level_5", + "preheat": false, + "switch_1": false, + "switch_2": false, + "level_1": "level_5", + "level_2": "level_5", + "preheat_1": false, + "preheat_2": false + } +} diff --git a/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json new file mode 100644 index 00000000000..9a82643e2f9 --- /dev/null +++ b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json @@ -0,0 +1,132 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Ceiling Fan With Light", + "category": "fs", + "product_id": "g0ewlb1vmwqljzji", + "product_name": "Ceiling Fan With Light", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-22T22:57:04+00:00", + "create_time": "2025-03-22T22:57:04+00:00", + "update_time": "2025-03-22T22:57:04+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status": { + "switch": true, + "mode": "normal", + "fan_speed": 1, + "fan_direction": "reverse", + "light": true, + "bright_value": 100, + "temp_value": 0, + "countdown_set": "off" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json new file mode 100644 index 00000000000..e8c59f50d7f --- /dev/null +++ b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Ventilador Cama", + "category": "fs", + "product_id": "ibytpo6fpnugft1c", + "product_name": "Tower bladeless fan ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-10T18:47:46+00:00", + "create_time": "2025-01-10T18:47:46+00:00", + "update_time": "2025-01-10T18:47:46+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/fsd_9ecs16c53uqskxw6.json b/tests/components/tuya/fixtures/fsd_9ecs16c53uqskxw6.json new file mode 100644 index 00000000000..92c83e9e8f0 --- /dev/null +++ b/tests/components/tuya/fixtures/fsd_9ecs16c53uqskxw6.json @@ -0,0 +1,151 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "ceiling fan/Light v2", + "category": "fsd", + "product_id": "9ecs16c53uqskxw6", + "product_name": "ceiling fan/Light v2", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-08T17:05:22+00:00", + "create_time": "2025-07-08T17:05:22+00:00", + "update_time": "2025-07-08T17:05:22+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "fan_switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 6, + "scale": 0, + "step": 1 + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "countdown_left_fan": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 540, + "scale": 0, + "step": 1 + } + }, + "fan_beep": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "fan_switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 6, + "scale": 0, + "step": 1 + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "countdown_left_fan": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 540, + "scale": 0, + "step": 1 + } + }, + "fan_beep": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "temp_value": 0, + "scene_data": "", + "fan_switch": true, + "fan_speed": 2, + "fan_direction": "forward", + "countdown_left_fan": 0, + "fan_beep": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json new file mode 100644 index 00000000000..62723670973 --- /dev/null +++ b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json @@ -0,0 +1,263 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Colorful PIR Night Light", + "category": "gyd", + "product_id": "lgekqfxdabipm3tn", + "product_name": "Colorful PIR Night Light", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2024-07-18T12:02:37+00:00", + "create_time": "2024-07-18T12:02:37+00:00", + "update_time": "2024-07-18T12:02:37+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "pir_state": { + "type": "Enum", + "value": { + "range": ["pir", "none"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value": 1000, + "temp_value": 1, + "colour_data": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown": 0, + "device_mode": "auto", + "pir_state": "none", + "cds": "5lux", + "pir_sensitivity": "middle", + "pir_delay": 30, + "switch_pir": true, + "standby_time": 1, + "standby_bright": 146 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/hps_2aaelwxk.json b/tests/components/tuya/fixtures/hps_2aaelwxk.json new file mode 100644 index 00000000000..77c4ad47839 --- /dev/null +++ b/tests/components/tuya/fixtures/hps_2aaelwxk.json @@ -0,0 +1,184 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Human presence Office", + "category": "hps", + "product_id": "2aaelwxk", + "product_name": "Human presence sensor", + "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-12-31T14:40:17+00:00", + "create_time": "2023-12-31T14:40:17+00:00", + "update_time": "2023-12-31T14:40:17+00:00", + "function": { + "sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "near_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "presence_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 28800, + "scale": 0, + "step": 1 + } + }, + "motionless_far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 600, + "scale": 0, + "step": 1 + } + }, + "motionless_sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "indicator": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "presence_state": { + "type": "Enum", + "value": { + "range": ["none", "presence"] + } + }, + "sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "near_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "human_motion_state": { + "type": "Enum", + "value": { + "range": ["none", "large_move", "small_move"] + } + }, + "presence_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 28800, + "scale": 0, + "step": 1 + } + }, + "motionless_far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 600, + "scale": 0, + "step": 1 + } + }, + "motionless_sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "illuminance_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 6000, + "scale": 0, + "step": 1 + } + }, + "indicator": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "presence_state": "none", + "sensitivity": 3, + "near_detection": 40, + "far_detection": 220, + "human_motion_state": "none", + "presence_time": 30, + "motionless_far_detection": 30, + "motionless_sensitivity": 7, + "illuminance_value": 0, + "indicator": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/hps_wqashyqo.json b/tests/components/tuya/fixtures/hps_wqashyqo.json new file mode 100644 index 00000000000..ad784b34aa9 --- /dev/null +++ b/tests/components/tuya/fixtures/hps_wqashyqo.json @@ -0,0 +1,41 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Soil moisture sensor #1", + "category": "hps", + "product_id": "wqashyqo", + "product_name": "Soil moisture sensor", + "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2025-06-03T10:48:45+00:00", + "create_time": "2025-06-03T10:48:45+00:00", + "update_time": "2025-06-03T10:48:45+00:00", + "function": {}, + "status_range": { + "presence_state": { + "type": "Enum", + "value": { + "range": ["none"] + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "presence_state": "none", + "humidity_value": 59 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json b/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json new file mode 100644 index 00000000000..228f4848d5e --- /dev/null +++ b/tests/components/tuya/fixtures/hwsb_ircs2n82vgrozoew.json @@ -0,0 +1,34 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "InverFlow", + "category": "hwsb", + "product_id": "ircs2n82vgrozoew", + "product_name": "InverFlow", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-08T12:44:43+00:00", + "create_time": "2025-08-08T12:44:43+00:00", + "update_time": "2025-08-08T12:44:43+00:00", + "function": {}, + "status_range": { + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 3000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "cur_power": 405 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/jtmspro_xqeob8h6.json b/tests/components/tuya/fixtures/jtmspro_xqeob8h6.json new file mode 100644 index 00000000000..e18b537c7e6 --- /dev/null +++ b/tests/components/tuya/fixtures/jtmspro_xqeob8h6.json @@ -0,0 +1,398 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "S1-TY-BLE-PRO 2", + "category": "jtmspro", + "product_id": "xqeob8h6", + "product_name": "S1-TY-BLE-PRO", + "online": false, + "sub": true, + "time_zone": "+03:00", + "active_time": "2025-07-01T15:21:36+00:00", + "create_time": "2025-07-01T15:21:36+00:00", + "update_time": "2025-07-01T15:21:36+00:00", + "function": { + "unlock_method_create": { + "type": "Raw", + "value": {} + }, + "unlock_method_delete": { + "type": "Raw", + "value": {} + }, + "unlock_method_modify": { + "type": "Raw", + "value": {} + }, + "lock_record": { + "type": "Raw", + "value": {} + }, + "message": { + "type": "Boolean", + "value": {} + }, + "automatic_lock": { + "type": "Boolean", + "value": {} + }, + "unlock_switch": { + "type": "Enum", + "value": { + "range": ["single_unlock", "finger_card"] + } + }, + "auto_lock_time": { + "type": "Integer", + "value": { + "min": 1, + "max": 1800, + "scale": 0, + "step": 1 + } + }, + "rtc_lock": { + "type": "Boolean", + "value": {} + }, + "manual_lock": { + "type": "Boolean", + "value": {} + }, + "synch_method": { + "type": "Raw", + "value": {} + }, + "remote_no_dp_key": { + "type": "Raw", + "value": {} + }, + "record": { + "type": "Raw", + "value": {} + }, + "check_code_set": { + "type": "Raw", + "value": {} + }, + "ble_unlock_check": { + "type": "Raw", + "value": {} + }, + "remote_pd_setkey_check": { + "type": "Raw", + "value": {} + }, + "unlock_ble_ibeacon": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "ibeacon_scan_mode": { + "type": "Enum", + "value": { + "range": [ + "always", + "5min", + "10min", + "20min", + "40min", + "60min", + "90min", + "120min" + ] + } + }, + "rssi_sensitivity_level": { + "type": "Enum", + "value": { + "range": [ + "inactive", + "90db", + "80db", + "70db", + "60db", + "50db", + "40db", + "30db", + "20db" + ] + } + }, + "ibeacon_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "unlock_method_create": { + "type": "Raw", + "value": {} + }, + "unlock_method_delete": { + "type": "Raw", + "value": {} + }, + "unlock_method_modify": { + "type": "Raw", + "value": {} + }, + "residual_electricity": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "unlock_fingerprint": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "unlock_card": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "unlock_key": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "unlock_ble": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "lock_record": { + "type": "Raw", + "value": {} + }, + "alarm_lock": { + "type": "Enum", + "value": { + "range": [ + "wrong_finger", + "wrong_password", + "wrong_card", + "wrong_face", + "tongue_bad", + "too_hot", + "unclosed_time", + "tongue_not_out", + "pry", + "key_in", + "low_battery", + "power_off", + "shock", + "defense" + ] + } + }, + "hijack": { + "type": "Boolean", + "value": {} + }, + "doorbell": { + "type": "Boolean", + "value": {} + }, + "message": { + "type": "Boolean", + "value": {} + }, + "automatic_lock": { + "type": "Boolean", + "value": {} + }, + "unlock_switch": { + "type": "Enum", + "value": { + "range": ["single_unlock", "finger_card"] + } + }, + "auto_lock_time": { + "type": "Integer", + "value": { + "min": 1, + "max": 1800, + "scale": 0, + "step": 1 + } + }, + "closed_opened": { + "type": "Enum", + "value": { + "range": ["unknown", "open", "closed"] + } + }, + "rtc_lock": { + "type": "Boolean", + "value": {} + }, + "manual_lock": { + "type": "Boolean", + "value": {} + }, + "lock_motor_state": { + "type": "Boolean", + "value": {} + }, + "synch_method": { + "type": "Raw", + "value": {} + }, + "remote_no_dp_key": { + "type": "Raw", + "value": {} + }, + "unlock_phone_remote": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "unlock_voice_remote": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "record": { + "type": "Raw", + "value": {} + }, + "check_code_set": { + "type": "Raw", + "value": {} + }, + "ble_unlock_check": { + "type": "Raw", + "value": {} + }, + "unlock_record_check": { + "type": "Raw", + "value": {} + }, + "remote_pd_setkey_check": { + "type": "Raw", + "value": {} + }, + "unlock_double_kit": { + "type": "Raw", + "value": {} + }, + "unlock_ble_ibeacon": { + "type": "Integer", + "value": { + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "ibeacon_scan_mode": { + "type": "Enum", + "value": { + "range": [ + "always", + "5min", + "10min", + "20min", + "40min", + "60min", + "90min", + "120min" + ] + } + }, + "rssi_sensitivity_level": { + "type": "Enum", + "value": { + "range": [ + "inactive", + "90db", + "80db", + "70db", + "60db", + "50db", + "40db", + "30db", + "20db" + ] + } + }, + "ibeacon_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "unlock_method_create": "A/8BAQIAAA==", + "unlock_method_delete": "", + "unlock_method_modify": "", + "residual_electricity": 99, + "unlock_fingerprint": 1, + "unlock_card": 0, + "unlock_key": 0, + "unlock_ble": 1, + "lock_record": "", + "alarm_lock": "wrong_finger", + "hijack": false, + "doorbell": false, + "message": false, + "automatic_lock": true, + "unlock_switch": "single_unlock", + "auto_lock_time": 3, + "closed_opened": "unknown", + "rtc_lock": false, + "manual_lock": true, + "lock_motor_state": false, + "synch_method": "AQA=", + "remote_no_dp_key": "AAAB", + "unlock_phone_remote": 1, + "unlock_voice_remote": 0, + "record": "AAEB", + "check_code_set": "AAH//wAAAAAAAAAAAP//AA==", + "ble_unlock_check": "AAH//zY2MDkzNTA0AWhkA/QAAA==", + "unlock_record_check": "", + "remote_pd_setkey_check": "AAH//zY2MDkzNTA0AQABAA==", + "unlock_double_kit": "", + "unlock_ble_ibeacon": 0, + "ibeacon_scan_mode": "always", + "rssi_sensitivity_level": "inactive", + "ibeacon_switch": false + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kg_4nqs33emdwJxpQ8O.json b/tests/components/tuya/fixtures/kg_4nqs33emdwJxpQ8O.json new file mode 100644 index 00000000000..bb6f8a8bba8 --- /dev/null +++ b/tests/components/tuya/fixtures/kg_4nqs33emdwJxpQ8O.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "office lights", + "category": "kg", + "product_id": "4nqs33emdwJxpQ8O", + "product_name": "SWITCH1", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-04-28T06:09:14+00:00", + "create_time": "2023-04-28T06:09:14+00:00", + "update_time": "2023-04-28T06:09:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kg_5ftkaulg.json b/tests/components/tuya/fixtures/kg_5ftkaulg.json new file mode 100644 index 00000000000..4b629f86375 --- /dev/null +++ b/tests/components/tuya/fixtures/kg_5ftkaulg.json @@ -0,0 +1,80 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "bathroom light", + "category": "kg", + "product_id": "5ftkaulg", + "product_name": "1Gang Zigbee Switch", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2023-07-04T15:11:35+00:00", + "create_time": "2023-07-04T15:11:35+00:00", + "update_time": "2023-07-04T15:11:35+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": "power_off", + "light_mode": "pos" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json new file mode 100644 index 00000000000..a61ebc52659 --- /dev/null +++ b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "name": "QT-Switch", + "category": "kg", + "product_id": "gbm9ata1zrzaez4a", + "product_name": "Smart Valve", + "online": false, + "sub": false, + "time_zone": "-05:00", + "active_time": "2020-01-27T23:37:47+00:00", + "create_time": "2020-01-27T23:37:47+00:00", + "update_time": "2020-01-27T23:37:47+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json new file mode 100644 index 00000000000..4e148140624 --- /dev/null +++ b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json @@ -0,0 +1,84 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "HL400", + "category": "kj", + "product_id": "CAjWAxBUZt7QZHfz", + "product_name": "air purifier", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-13T11:02:55+00:00", + "create_time": "2025-05-13T11:02:55+00:00", + "update_time": "2025-05-13T11:02:55+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "uv": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "lock": false, + "anion": true, + "speed": 3, + "uv": true, + "pm25": 45 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_fsxtzzhujkrak2oy.json b/tests/components/tuya/fixtures/kj_fsxtzzhujkrak2oy.json new file mode 100644 index 00000000000..1fe8ead167f --- /dev/null +++ b/tests/components/tuya/fixtures/kj_fsxtzzhujkrak2oy.json @@ -0,0 +1,104 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kalado Air Purifier", + "category": "kj", + "product_id": "fsxtzzhujkrak2oy", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-06-23T16:03:21+00:00", + "create_time": "2024-06-23T16:03:21+00:00", + "update_time": "2024-06-23T16:03:21+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto", "sleep"] + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "ug/m3", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto", "sleep"] + } + }, + "filter": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + }, + "air_quality": { + "type": "Enum", + "value": { + "range": ["great", "good", "medium", "severe"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["e1", "e2"] + } + } + }, + "status": { + "switch": false, + "pm25": 3, + "mode": "auto", + "filter": 42, + "filter_reset": false, + "countdown_set": "cancel", + "air_quality": "great", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_s4uzibibgzdxzowo.json b/tests/components/tuya/fixtures/kj_s4uzibibgzdxzowo.json new file mode 100644 index 00000000000..b4ae9a35391 --- /dev/null +++ b/tests/components/tuya/fixtures/kj_s4uzibibgzdxzowo.json @@ -0,0 +1,115 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "ION1000PRO", + "category": "kj", + "product_id": "s4uzibibgzdxzowo", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2023-12-21T05:50:50+00:00", + "create_time": "2023-12-21T05:50:50+00:00", + "update_time": "2023-12-21T05:50:50+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "filter_days": { + "type": "Integer", + "value": { + "unit": "Hours", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 720, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "pm25": 9, + "anion": false, + "lock": true, + "uv": true, + "filter_reset": false, + "filter_days": 0, + "countdown_set": "cancle", + "countdown_left": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json new file mode 100644 index 00000000000..45015bff0ac --- /dev/null +++ b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bree", + "category": "kj", + "product_id": "yrzylxax1qspdgpp", + "product_name": "40\" Bladeless Tower Fan", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-22T07:35:33+00:00", + "create_time": "2025-06-22T07:35:33+00:00", + "update_time": "2025-06-22T07:35:33+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["sleep"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["sleep"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 1440, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "switch": false, + "mode": "normal", + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json new file mode 100644 index 00000000000..b36064724af --- /dev/null +++ b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json @@ -0,0 +1,105 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Tower Fan CA-407G Smart", + "category": "ks", + "product_id": "j9fa8ahzac8uvlfl", + "product_name": "Tower Fan CA-407G Smart", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-14T11:22:54+00:00", + "create_time": "2025-07-14T11:22:54+00:00", + "update_time": "2025-07-14T11:22:54+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 721, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "fan_speed": 5, + "mode": "ordinary", + "switch_horizontal": true, + "anion": false, + "light": true, + "countdown_left": 0 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json new file mode 100644 index 00000000000..3dd9c3713dc --- /dev/null +++ b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json @@ -0,0 +1,78 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Air Conditioner", + "category": "kt", + "product_id": "5wnlzekkstwcdsvm", + "product_name": "\u79fb\u52a8\u7a7a\u8c03 YPK--\uff08\u53cc\u6a21+\u84dd\u7259\uff09\u4f4e\u529f\u8017", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-07-06T10:10:44+00:00", + "create_time": "2025-07-06T10:10:44+00:00", + "update_time": "2025-07-06T10:10:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": -7, + "max": 98, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "switch": false, + "temp_set": 23, + "temp_current": 22, + "windspeed": 1 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kt_ibmmirhhq62mmf1g.json b/tests/components/tuya/fixtures/kt_ibmmirhhq62mmf1g.json new file mode 100644 index 00000000000..e7657a7b0e9 --- /dev/null +++ b/tests/components/tuya/fixtures/kt_ibmmirhhq62mmf1g.json @@ -0,0 +1,110 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Master Bedroom AC", + "category": "kt", + "product_id": "ibmmirhhq62mmf1g", + "product_name": "T platform model-USB ", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2025-07-16T14:12:18+00:00", + "create_time": "2025-07-16T14:12:18+00:00", + "update_time": "2025-07-16T14:12:18+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 160, + "max": 880, + "scale": 1, + "step": 5 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot", "wet", "wind", "auto"] + } + }, + "temp_set_f": { + "type": "Integer", + "value": { + "unit": "\u2109", + "min": 61, + "max": 88, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 160, + "max": 880, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -20, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot", "wet", "wind", "auto"] + } + }, + "humidity_current": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_set_f": { + "type": "Integer", + "value": { + "unit": "\u2109", + "min": 61, + "max": 88, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "temp_set": 750, + "temp_current": 26, + "mode": "cold", + "humidity_current": 0, + "temp_set_f": 61 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kt_vdadlnmsorlhw4td.json b/tests/components/tuya/fixtures/kt_vdadlnmsorlhw4td.json new file mode 100644 index 00000000000..0f07e4a13e7 --- /dev/null +++ b/tests/components/tuya/fixtures/kt_vdadlnmsorlhw4td.json @@ -0,0 +1,78 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sove", + "category": "kt", + "product_id": "vdadlnmsorlhw4td", + "product_name": "YFA-05C", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-07T11:44:00+00:00", + "create_time": "2025-07-07T11:44:00+00:00", + "update_time": "2025-07-07T11:44:00+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -7, + "max": 110, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "switch": false, + "temp_set": 16, + "temp_current": 24, + "windspeed": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ldcg_9kbbfeho.json b/tests/components/tuya/fixtures/ldcg_9kbbfeho.json new file mode 100644 index 00000000000..6281085a06c --- /dev/null +++ b/tests/components/tuya/fixtures/ldcg_9kbbfeho.json @@ -0,0 +1,45 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Luminosité", + "category": "ldcg", + "product_id": "9kbbfeho", + "product_name": "Luminance sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-25T16:37:21+00:00", + "create_time": "2025-07-25T16:37:21+00:00", + "update_time": "2025-07-25T16:37:21+00:00", + "function": {}, + "status_range": { + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "bright_value": 16, + "battery_percentage": 91 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json b/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json new file mode 100644 index 00000000000..ee69a811a92 --- /dev/null +++ b/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json @@ -0,0 +1,224 @@ +{ + "name": "Multifunction alarm", + "category": "mal", + "product_id": "gyitctrjj1kefxp2", + "product_name": "Multifunction alarm", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-12-02T20:08:56+00:00", + "create_time": "2024-12-02T20:08:56+00:00", + "update_time": "2024-12-02T20:08:56+00:00", + "function": { + "master_mode": { + "type": "Enum", + "value": { + "range": ["disarmed", "arm", "home", "sos"] + } + }, + "delay_set": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_light": { + "type": "Boolean", + "value": {} + }, + "switch_mode_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_light": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_propel": { + "type": "Boolean", + "value": {} + }, + "alarm_delay_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "sub_class": { + "type": "Enum", + "value": { + "range": ["remote_controller", "detector"] + } + }, + "sub_admin": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "master_mode": { + "type": "Enum", + "value": { + "range": ["disarmed", "arm", "home", "sos"] + } + }, + "delay_set": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_light": { + "type": "Boolean", + "value": {} + }, + "switch_mode_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_light": { + "type": "Boolean", + "value": {} + }, + "telnet_state": { + "type": "Enum", + "value": { + "range": [ + "normal", + "network_no", + "phone_no", + "sim_card_no", + "network_search", + "signal_level_1", + "signal_level_2", + "signal_level_3", + "signal_level_4", + "signal_level_5" + ] + } + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "alarm_msg": { + "type": "Raw", + "value": {} + }, + "switch_alarm_propel": { + "type": "Boolean", + "value": {} + }, + "alarm_delay_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "sub_class": { + "type": "Enum", + "value": { + "range": ["remote_controller", "detector"] + } + }, + "sub_admin": { + "type": "Raw", + "value": {} + }, + "sub_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm", "fault", "others"] + } + } + }, + "status": { + "master_mode": "disarmed", + "delay_set": 15, + "alarm_time": 3, + "switch_alarm_sound": true, + "switch_alarm_light": true, + "switch_mode_sound": true, + "switch_kb_sound": false, + "switch_kb_light": false, + "telnet_state": "sim_card_no", + "muffling": false, + "alarm_msg": "AFMAZQBuAHMAbwByACAATABvAHcAIABCAGEAdAB0AGUAcgB5AAoAWgBvAG4AZQA6ADAAMAA1AEUAbgB0AHIAYQBuAGMAZQ==", + "switch_alarm_propel": true, + "alarm_delay_time": 20, + "master_state": "normal", + "sub_class": "remote_controller", + "sub_admin": "AgEFCggC////HABLAGkAdABjAGgAZQBuACAAUwBtAG8AawBlACBjAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADFkAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADJlAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADNmAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADQ=", + "sub_state": "normal" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json b/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json new file mode 100644 index 00000000000..16d51063dc1 --- /dev/null +++ b/tests/components/tuya/fixtures/mc_oSQljE9YDqwCwTUA.json @@ -0,0 +1,35 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kippenluik", + "category": "mc", + "product_id": "oSQljE9YDqwCwTUA", + "product_name": "Door Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-10-28T09:22:24+00:00", + "create_time": "2023-10-28T09:22:24+00:00", + "update_time": "2023-10-28T09:22:24+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "doorcontact_state": true, + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_6ywsnauy.json b/tests/components/tuya/fixtures/mcs_6ywsnauy.json new file mode 100644 index 00000000000..8612a42c0c6 --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_6ywsnauy.json @@ -0,0 +1,44 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Fen\u00eatre cuisine", + "category": "mcs", + "product_id": "6ywsnauy", + "product_name": "Contact Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:54:49+00:00", + "create_time": "2025-07-19T11:54:49+00:00", + "update_time": "2025-07-19T11:54:49+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temper_alarm": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "doorcontact_state": false, + "battery_percentage": 93, + "temper_alarm": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json b/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json new file mode 100644 index 00000000000..0e0a947aff7 --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json @@ -0,0 +1,41 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "380", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Door Garage ", + "model": "", + "category": "mcs", + "product_id": "7jIGJAymiH8OsFFb", + "product_name": "Door Sensor", + "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2024-01-18T12:27:56+00:00", + "create_time": "2024-01-18T12:27:56+00:00", + "update_time": "2024-01-18T12:29:19+00:00", + "function": {}, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "battery": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "battery": 100 + } +} diff --git a/tests/components/tuya/fixtures/mcs_8yhypbo7.json b/tests/components/tuya/fixtures/mcs_8yhypbo7.json new file mode 100644 index 00000000000..ee5e125acd5 --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_8yhypbo7.json @@ -0,0 +1,39 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bo\u00eete aux lettres - arri\u00e8re", + "category": "mcs", + "product_id": "8yhypbo7", + "product_name": "Door Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:44:37+00:00", + "create_time": "2025-07-19T11:44:37+00:00", + "update_time": "2025-07-19T11:44:37+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "doorcontact_state": false, + "battery_percentage": 62 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_hx5ztlztij4yxxvg.json b/tests/components/tuya/fixtures/mcs_hx5ztlztij4yxxvg.json new file mode 100644 index 00000000000..b0011708edf --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_hx5ztlztij4yxxvg.json @@ -0,0 +1,35 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "name": "Steel cage door", + "category": "mcs", + "product_id": "hx5ztlztij4yxxvg", + "product_name": "Door Detector", + "online": true, + "sub": false, + "time_zone": "-05:00", + "active_time": "2020-05-28T22:07:06+00:00", + "create_time": "2020-05-28T22:07:06+00:00", + "update_time": "2020-05-28T22:07:06+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "doorcontact_state": false, + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json b/tests/components/tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json new file mode 100644 index 00000000000..708de40152f --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json @@ -0,0 +1,39 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage Contact Sensor", + "category": "mcs", + "product_id": "qxu3flpqjsc1kqu3", + "product_name": "Contact Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-03-11T23:28:59+00:00", + "create_time": "2023-03-11T23:28:59+00:00", + "update_time": "2023-03-11T23:28:59+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "doorcontact_state": false, + "battery_percentage": 11 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json new file mode 100644 index 00000000000..df6375a6827 --- /dev/null +++ b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json @@ -0,0 +1,117 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sous Vide", + "category": "mzj", + "product_id": "qavcakohisj5adyh", + "product_name": "Sous Vide", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-08T17:56:06+00:00", + "create_time": "2025-01-08T17:56:06+00:00", + "update_time": "2025-01-08T17:56:06+00:00", + "function": { + "start": { + "type": "Boolean", + "value": {} + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "start": { + "type": "Boolean", + "value": {} + }, + "status": { + "type": "Enum", + "value": { + "range": ["standby", "cooking", "done"] + } + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "remain_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "start": false, + "status": "standby", + "cook_time": 1, + "remain_time": 1, + "cook_temperature": 550, + "temp_current": 267, + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json b/tests/components/tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json new file mode 100644 index 00000000000..0bc304817b4 --- /dev/null +++ b/tests/components/tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u0411\u0440\u0438\u0437\u0435\u0440 \u0417\u0430\u043b", + "category": "ntq", + "product_id": "9mqdhwklpvnnvb7t", + "product_name": "TION Breezer Bio X", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2024-08-16T14:49:45+00:00", + "create_time": "2024-08-16T14:49:45+00:00", + "update_time": "2024-08-16T14:49:45+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json b/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json new file mode 100644 index 00000000000..aa16d5a91d8 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json @@ -0,0 +1,42 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bubbelbad", + "category": "pc", + "product_id": "t2afic7i3v1bwhfp", + "product_name": "Garden Spike(EU)", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-02-02T15:10:03+00:00", + "create_time": "2022-02-02T15:10:03+00:00", + "update_time": "2022-02-02T15:10:03+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "switch_2": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json b/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json new file mode 100644 index 00000000000..ddff6df21a1 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json @@ -0,0 +1,84 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Terras", + "category": "pc", + "product_id": "trjopo1vdlt9q1tg", + "product_name": "Garden Spike(FR)", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-18T08:42:44+00:00", + "create_time": "2023-01-18T08:42:44+00:00", + "update_time": "2023-01-18T08:42:44+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "switch_2": false, + "countdown_1": 0, + "countdown_2": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pc_tsbguim4trl6fa7g.json b/tests/components/tuya/fixtures/pc_tsbguim4trl6fa7g.json new file mode 100644 index 00000000000..045b6383f72 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_tsbguim4trl6fa7g.json @@ -0,0 +1,188 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Keller", + "category": "pc", + "product_id": "tsbguim4trl6fa7g", + "product_name": "Smart Power Strip", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-18T14:24:59+00:00", + "create_time": "2025-03-18T14:24:59+00:00", + "update_time": "2025-03-18T14:24:59+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_usb1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_usb1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_usb1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_usb1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "switch_2": true, + "switch_3": false, + "switch_usb1": false, + "countdown_1": 0, + "countdown_2": 0, + "countdown_3": 0, + "countdown_usb1": 0, + "add_ele": 2, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pc_yku9wsimasckdt15.json b/tests/components/tuya/fixtures/pc_yku9wsimasckdt15.json new file mode 100644 index 00000000000..2d843bdf058 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_yku9wsimasckdt15.json @@ -0,0 +1,189 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Framboisier", + "category": "pc", + "product_id": "yku9wsimasckdt15", + "product_name": "Konyks Priska USB", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-19T09:45:39+00:00", + "create_time": "2025-07-19T09:45:39+00:00", + "update_time": "2025-07-19T09:45:39+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "switch_2": true, + "countdown_1": 0, + "countdown_2": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2471, + "relay_status": "power_on", + "light_mode": "none", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json b/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json new file mode 100644 index 00000000000..6e68b1a92db --- /dev/null +++ b/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json @@ -0,0 +1,42 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "rat trap hedge", + "category": "pir", + "product_id": "3amxzozho9xp4mkh", + "product_name": "Smart Motion Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-11-17T11:36:06+00:00", + "create_time": "2021-11-17T11:36:06+00:00", + "update_time": "2021-11-17T11:36:06+00:00", + "function": {}, + "status_range": { + "pir": { + "type": "Enum", + "value": { + "range": ["pir"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temper_alarm": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "pir": "pir", + "battery_state": "low", + "temper_alarm": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pir_fcdjzz3s.json b/tests/components/tuya/fixtures/pir_fcdjzz3s.json new file mode 100644 index 00000000000..74f223ee7ea --- /dev/null +++ b/tests/components/tuya/fixtures/pir_fcdjzz3s.json @@ -0,0 +1,46 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Motion sensor lidl zigbee", + "category": "pir", + "product_id": "fcdjzz3s", + "product_name": "Motion sensor", + "online": false, + "sub": true, + "time_zone": "+01:00", + "active_time": "2022-09-09T07:24:07+00:00", + "create_time": "2022-09-09T07:24:07+00:00", + "update_time": "2022-09-09T07:24:07+00:00", + "function": {}, + "status_range": { + "pir": { + "type": "Enum", + "value": { + "range": ["pir"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temper_alarm": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "pir": "nopir", + "battery_percentage": 85, + "temper_alarm": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json b/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json new file mode 100644 index 00000000000..8bf85a1d339 --- /dev/null +++ b/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "PIR outside stairs", + "category": "pir", + "product_id": "wqz93nrdomectyoz", + "product_name": "Smart PIR sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-26T09:56:39+00:00", + "create_time": "2023-01-26T09:56:39+00:00", + "update_time": "2023-01-26T09:56:39+00:00", + "function": {}, + "status_range": { + "pir": { + "type": "Enum", + "value": { + "range": ["pir"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "pir": "pir", + "battery_state": "middle" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json new file mode 100644 index 00000000000..97c4a21526c --- /dev/null +++ b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json @@ -0,0 +1,103 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "AC charging control box", + "category": "qccdz", + "product_id": "7bvgooyjhiua1yyq", + "product_name": "AC charging control box", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-21T17:00:03+00:00", + "create_time": "2025-01-21T17:00:03+00:00", + "update_time": "2025-01-21T17:00:03+00:00", + "function": { + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "work_state": { + "type": "Enum", + "value": { + "range": [ + "charger_free", + "charger_insert", + "charger_free_fault", + "charger_wait", + "charger_charging", + "charger_pause", + "charger_end", + "charger_fault" + ] + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 3, + "step": 1 + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "charge_energy_once": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 1, + "max": 999999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "work_state": "charger_free", + "work_mode": "charge_now", + "balance_energy": 0, + "clear_energy": false, + "switch": false, + "charge_energy_once": 1 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json b/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json new file mode 100644 index 00000000000..37f16b0d40a --- /dev/null +++ b/tests/components/tuya/fixtures/qn_5ls2jw49hpczwqng.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Mr. Pure", + "category": "qn", + "product_id": "5ls2jw49hpczwqng", + "product_name": "Mr. Pure", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-08T12:47:09+00:00", + "create_time": "2025-08-08T12:47:09+00:00", + "update_time": "2025-08-08T12:47:09+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json new file mode 100644 index 00000000000..549e23cc914 --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json @@ -0,0 +1,410 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "category": "qxj", + "product_id": "fsea1lat3vuktbt6", + "product_name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-07T17:43:41+00:00", + "create_time": "2025-07-07T17:43:41+00:00", + "update_time": "2025-07-07T17:43:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + } + }, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + }, + "fault_type": { + "type": "Enum", + "value": { + "range": [ + "normal", + "ch1_offline", + "ch2_offline", + "ch3_offline", + "offline" + ] + } + }, + "battery_status": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_1": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_2": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_3": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_1": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_1": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_2": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_3": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_3": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "atmospheric_pressture": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 3000, + "max": 12000, + "scale": 1, + "step": 1 + } + }, + "pressure_drop": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "windspeed_avg": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "windspeed_gust": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "wind_direct": { + "type": "Enum", + "value": { + "range": [ + "north", + "north_north_east", + "north_east", + "east_north_east", + "east", + "east_south_east", + "south_east", + "south_south_east", + "south", + "south_south_west", + "south_west", + "west_south_west", + "west", + "west_north_west", + "north_west", + "north_north_west" + ] + } + }, + "rain_24h": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 1000000, + "scale": 3, + "step": 1 + } + }, + "rain_rate": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 999999, + "scale": 3, + "step": 1 + } + }, + "uv_index": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 180, + "scale": 1, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 238000, + "scale": 0, + "step": 100 + } + }, + "dew_point_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "feellike_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "heat_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 260, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "windchill_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "com_index": { + "type": "Enum", + "value": { + "range": ["moist", "dry", "comfortable"] + } + } + }, + "status": { + "temp_current": 240, + "humidity_value": 52, + "battery_state": "high", + "temp_unit_convert": "c", + "windspeed_unit_convert": "m_s", + "pressure_unit_convert": "hpa", + "rain_unit_convert": "mm", + "bright_unit_convert": "lux", + "fault_type": "normal", + "battery_status": "low", + "battery_state_1": "high", + "battery_state_2": "high", + "battery_state_3": "low", + "temp_current_external": -400, + "humidity_outdoor": 0, + "temp_current_external_1": 193, + "humidity_outdoor_1": 99, + "temp_current_external_2": 252, + "humidity_outdoor_2": 0, + "temp_current_external_3": -400, + "humidity_outdoor_3": 0, + "atmospheric_pressture": 10040, + "pressure_drop": 0, + "windspeed_avg": 0, + "windspeed_gust": 0, + "wind_direct": "none", + "rain_24h": 0, + "rain_rate": 0, + "uv_index": 0, + "bright_value": 0, + "dew_point_temp": -400, + "feellike_temp": -650, + "heat_index": 260, + "windchill_index": -650, + "com_index": "none" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json new file mode 100644 index 00000000000..93b3aa580a0 --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json @@ -0,0 +1,63 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Frysen", + "category": "qxj", + "product_id": "is2indt9nlth6esa", + "product_name": "T & H Sensor with external probe", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-27T15:19:27+00:00", + "create_time": "2025-01-27T15:19:27+00:00", + "update_time": "2025-01-27T15:19:27+00:00", + "function": {}, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "temp_current": 222, + "humidity_value": 38, + "battery_state": "high", + "temp_current_external": -130 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json new file mode 100644 index 00000000000..6516626d789 --- /dev/null +++ b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json @@ -0,0 +1,88 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gas sensor", + "category": "rqbj", + "product_id": "4iqe2hsfyd86kwwc", + "product_name": "Gas sensor", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-24T20:33:10+00:00", + "create_time": "2025-06-24T20:33:10+00:00", + "update_time": "2025-06-24T20:33:10+00:00", + "function": { + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "self_checking": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "checking_result": { + "type": "Enum", + "value": { + "range": ["checking", "check_success", "check_failure", "others"] + } + }, + "gas_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "gas_sensor_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "self_checking": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "checking_result": "check_success", + "gas_sensor_status": "normal", + "alarm_time": 300, + "gas_sensor_value": 0, + "self_checking": false, + "muffling": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sd_i6hyjg3af7doaswm.json b/tests/components/tuya/fixtures/sd_i6hyjg3af7doaswm.json new file mode 100644 index 00000000000..15aab08ab4a --- /dev/null +++ b/tests/components/tuya/fixtures/sd_i6hyjg3af7doaswm.json @@ -0,0 +1,64 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Hoover", + "category": "sd", + "product_id": "i6hyjg3af7doaswm", + "product_name": "E20", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-05-05T15:25:07+00:00", + "create_time": "2023-05-05T15:25:07+00:00", + "update_time": "2023-05-05T15:25:07+00:00", + "function": { + "power": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["random", "smart", "wall_follow", "chargego"] + } + }, + "power_go": { + "type": "Boolean", + "value": {} + }, + "seek": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "power": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["random", "smart", "wall_follow", "chargego"] + } + }, + "power_go": { + "type": "Boolean", + "value": {} + }, + "seek": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "power": true, + "mode": "chargego", + "power_go": false, + "seek": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json new file mode 100644 index 00000000000..ba461a6226d --- /dev/null +++ b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json @@ -0,0 +1,474 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "V20", + "category": "sd", + "product_id": "lr33znaodtyarrrz", + "product_name": "V20", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-23T16:37:02+00:00", + "create_time": "2025-03-23T16:37:02+00:00", + "update_time": "2025-03-23T16:37:02+00:00", + "function": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "status": { + "type": "Enum", + "value": { + "range": [ + "standby", + "zone_clean", + "part_clean", + "cleaning", + "paused", + "goto_pos", + "pos_arrived", + "pos_unarrive", + "goto_charge", + "charging", + "charge_done", + "sleep" + ] + } + }, + "clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "electricity_left": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["closed", "gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["closed", "low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "edge_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "roll_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 18000, + "scale": 0, + "step": 1 + } + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "filter": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "duster_cloth": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "low_power", + "poweroff", + "wheel_trap", + "cannot_upgrade", + "collision_stuck", + "dust_station_full", + "tile_error", + "lidar_speed_err", + "lidar_cover", + "lidar_point_err", + "front_wall_dirty", + "psd_dirty", + "middle_sweep", + "side_sweep", + "fan_speed", + "dustbox_out", + "dustbox_full", + "no_dust_box", + "dustbox_fullout", + "trapped", + "pick_up", + "no_dust_water_box", + "water_box_empty", + "forbid_area", + "land_check", + "findcharge_fail", + "battery_err", + "kit_wheel", + "kit_lidar", + "kit_water_pump" + ] + } + }, + "total_clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_count": { + "type": "Integer", + "value": { + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "device_info": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "power_go": false, + "pause": false, + "switch_charge": false, + "mode": "goto_charge", + "status": "charge_done", + "clean_time": 0, + "clean_area": 0, + "electricity_left": 100, + "suction": "strong", + "cistern": "middle", + "seek": false, + "direction_control": "forward", + "reset_map": false, + "path_data": "", + "command_trans": "qgABFxc=", + "request": "get_map", + "edge_brush": 8944, + "reset_edge_brush": false, + "roll_brush": 17948, + "reset_roll_brush": false, + "filter": 8956, + "reset_filter": false, + "duster_cloth": 9000, + "reset_duster_cloth": false, + "switch_disturb": false, + "volume_set": 95, + "break_clean": true, + "fault": 0, + "total_clean_area": 24, + "total_clean_count": 1, + "total_clean_time": 42, + "device_timer": "qgADMQEAMg==", + "disturb_time_set": "qgAIMwEWAAAIAABS", + "device_info": "eyJEZXZpY2VfU04iOiJJRlYyMDI1MDExNTAyMDIwMiIsIkZpcm13YXJlX1ZlcnNpb24iOiIxLjQuMyIsIklQIjoiMTkyLjE2OC4wLjIwMyIsIk1DVV9WZXJzaW9uIjoiMC4zMTQxLjEwNyIsIk1hYyI6IjM0OjE3OjM2OkU1OjAyOjc4IiwiTW9kdWxlX1VVSUQiOiJ6ZjExYjJmNzQ4Mzg5ZTY5ZDk4NiIsIlJTU0kiOiItNTAiLCJXaUZpX05hbWUiOiJGcnl0a2lfemFfZGFybW8ifQ==", + "voice_data": "qwAAAAAHNQAAAAADZJw=", + "language": "chinese_simplified", + "customize_mode_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sfkzq_1fcnd8xk.json b/tests/components/tuya/fixtures/sfkzq_1fcnd8xk.json new file mode 100644 index 00000000000..c9ccae70d21 --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_1fcnd8xk.json @@ -0,0 +1,130 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Valve Controller 2", + "category": "sfkzq", + "product_id": "1fcnd8xk", + "product_name": "Valve Controller", + "online": false, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-07-16T09:37:13+00:00", + "create_time": "2023-07-16T09:37:13+00:00", + "update_time": "2023-07-16T09:37:13+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "time_use": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 2592000, + "scale": 0, + "step": 1 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "smart_weather": { + "type": "Enum", + "value": { + "range": ["sunny", "cloudy", "rainy"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "time_use": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 2592000, + "scale": 0, + "step": 1 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "idle"] + } + }, + "smart_weather": { + "type": "Enum", + "value": { + "range": ["sunny", "cloudy", "rainy"] + } + }, + "use_time_one": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "battery_percentage": 100, + "time_use": 14025, + "weather_delay": "cancel", + "countdown": 0, + "work_state": "idle", + "smart_weather": "sunny", + "use_time_one": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sfkzq_ed7frwissyqrejic.json b/tests/components/tuya/fixtures/sfkzq_ed7frwissyqrejic.json new file mode 100644 index 00000000000..e301e25930f --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_ed7frwissyqrejic.json @@ -0,0 +1,282 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u63a5HA\u6c34\u9600", + "category": "sfkzq", + "product_id": "ed7frwissyqrejic", + "product_name": "\u63a5HA\u6c34\u9600", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-07-14T06:32:48+00:00", + "create_time": "2025-07-14T06:32:48+00:00", + "update_time": "2025-07-14T06:32:48+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "switch_7": { + "type": "Boolean", + "value": {} + }, + "switch_8": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_7": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_8": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "switch_7": { + "type": "Boolean", + "value": {} + }, + "switch_8": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_7": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "countdown_8": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 1439, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "switch_1": true, + "switch_2": false, + "switch_3": true, + "switch_4": false, + "switch_5": true, + "switch_6": false, + "switch_7": true, + "switch_8": false, + "countdown_1": 1, + "countdown_2": 1, + "countdown_3": 3, + "countdown_4": 2, + "countdown_5": 2, + "countdown_6": 3, + "countdown_7": 1, + "countdown_8": 1, + "battery_percentage": 0, + "battery_state": "low" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json new file mode 100644 index 00000000000..30eff8b5c8b --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sprinkler Cesare", + "category": "sfkzq", + "product_id": "o6dagifntoafakst", + "product_name": "Valve Controller", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-19T07:56:02+00:00", + "create_time": "2025-06-19T07:56:02+00:00", + "update_time": "2025-06-19T07:56:02+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sfkzq_rzklytdei8i8vo37.json b/tests/components/tuya/fixtures/sfkzq_rzklytdei8i8vo37.json new file mode 100644 index 00000000000..2cfcf00cd53 --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_rzklytdei8i8vo37.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "balkonbew\u00e4sserung", + "category": "sfkzq", + "product_id": "rzklytdei8i8vo37", + "product_name": "Smart Water Timer", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-29T17:45:56+00:00", + "create_time": "2025-04-29T17:45:56+00:00", + "update_time": "2025-04-29T17:45:56+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cycle_time": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "idle"] + } + }, + "use_time_one": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "battery_percentage": 90, + "weather_delay": "cancel", + "countdown": 0, + "work_state": "idle", + "use_time_one": 52 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json b/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json new file mode 100644 index 00000000000..b0fd9d38bdf --- /dev/null +++ b/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json @@ -0,0 +1,67 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Siren veranda ", + "category": "sgbj", + "product_id": "ulv4nnue7gqp0rjk", + "product_name": "Siren Sensor", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2020-05-05T20:15:05+00:00", + "create_time": "2020-05-05T20:15:05+00:00", + "update_time": "2020-05-05T20:15:05+00:00", + "function": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 30, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 30, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "alarm_volume": "middle", + "alarm_time": 10, + "alarm_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sj_tgvtvdoc.json b/tests/components/tuya/fixtures/sj_tgvtvdoc.json new file mode 100644 index 00000000000..bba2d80da88 --- /dev/null +++ b/tests/components/tuya/fixtures/sj_tgvtvdoc.json @@ -0,0 +1,41 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Tournesol", + "category": "sj", + "product_id": "tgvtvdoc", + "product_name": "Rain sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-30T04:51:12+00:00", + "create_time": "2025-07-30T04:51:12+00:00", + "update_time": "2025-07-30T04:51:12+00:00", + "function": {}, + "status_range": { + "watersensor_state": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "watersensor_state": "normal", + "battery_percentage": 98 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json b/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json new file mode 100644 index 00000000000..ed30e930e2b --- /dev/null +++ b/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json @@ -0,0 +1,180 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "CAM GARAGE", + "category": "sp", + "product_id": "drezasavompxpcgm", + "product_name": "Indoor camera ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-07-26T13:26:21+00:00", + "create_time": "2021-07-26T13:26:21+00:00", + "update_time": "2021-07-26T13:26:21+00:00", + "function": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 5, + "scale": 1, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "unit": "", + "min": -20000, + "max": 20000, + "scale": 1, + "step": 1 + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "decibel_upload": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "basic_indicator": true, + "basic_flip": false, + "basic_osd": false, + "motion_sensitivity": 0, + "basic_nightvision": 0, + "sd_storge": "0|0|0", + "sd_status": 5, + "sd_format": false, + "movement_detect_pic": "**REDACTED**", + "sd_format_state": -20000, + "motion_switch": true, + "decibel_switch": false, + "decibel_sensitivity": 0, + "decibel_upload": 1696802404, + "record_switch": false, + "record_mode": 1 + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/sp_nzauwyj3mcnjnf35.json b/tests/components/tuya/fixtures/sp_nzauwyj3mcnjnf35.json new file mode 100644 index 00000000000..21d6b7db1d1 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_nzauwyj3mcnjnf35.json @@ -0,0 +1,220 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage Camera", + "category": "sp", + "product_id": "nzauwyj3mcnjnf35", + "product_name": "Smart Camera ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-14T11:37:22+00:00", + "create_time": "2024-01-14T11:37:22+00:00", + "update_time": "2024-01-14T11:37:22+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "sd_umount": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "motion_area_switch": { + "type": "Boolean", + "value": {} + }, + "motion_area": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "min": 1, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "sd_umount": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "min": -20000, + "max": 200000, + "scale": 0, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "motion_area_switch": { + "type": "Boolean", + "value": {} + }, + "motion_area": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "alarm_message": { + "type": "String", + "value": {} + } + }, + "status": { + "basic_flip": true, + "basic_osd": true, + "basic_private": false, + "motion_sensitivity": 0, + "sd_storge": "896|896|0", + "sd_status": 5, + "sd_format": false, + "sd_umount": false, + "motion_record": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 0, + "ptz_control": 3, + "motion_switch": false, + "record_switch": true, + "record_mode": 1, + "motion_tracking": false, + "motion_area_switch": true, + "motion_area": { + "num": 1, + "region0": { + "x": 0, + "y": 0, + "xlen": 100, + "ylen": 100 + } + }, + "alarm_message": "**REDACTED**" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json b/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json new file mode 100644 index 00000000000..6825c67efc2 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json @@ -0,0 +1,211 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "CAM PORCH", + "category": "sp", + "product_id": "rjKXWRohlvOTyLBu", + "product_name": "Indoor cam Pan/Tilt ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2020-07-04T07:41:28+00:00", + "create_time": "2020-07-04T07:41:28+00:00", + "update_time": "2020-07-04T07:41:28+00:00", + "function": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_timer_setting": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "motion_timer_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 5, + "scale": 1, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_timer_setting": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "unit": "", + "min": -20000, + "max": 20000, + "scale": 1, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "motion_timer_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "decibel_upload": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "basic_indicator": false, + "basic_flip": false, + "basic_osd": true, + "motion_sensitivity": 2, + "sd_storge": "100|0|100", + "sd_status": 5, + "sd_format": true, + "motion_timer_setting": "00:00|06:00", + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 100, + "ptz_control": 6, + "motion_switch": false, + "motion_timer_switch": true, + "decibel_switch": false, + "decibel_sensitivity": 1, + "decibel_upload": 1750049151, + "record_switch": false, + "record_mode": 1 + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/sp_rudejjigkywujjvs.json b/tests/components/tuya/fixtures/sp_rudejjigkywujjvs.json new file mode 100644 index 00000000000..06d3f0a2705 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_rudejjigkywujjvs.json @@ -0,0 +1,240 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "B\u00fcrocam", + "category": "sp", + "product_id": "rudejjigkywujjvs", + "product_name": "LSC PTZ Camera", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:19:09+00:00", + "create_time": "2025-03-15T13:19:09+00:00", + "update_time": "2025-03-15T13:19:09+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "basic_anti_flicker": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 5, + "scale": 1, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "unit": "", + "min": -20000, + "max": 20000, + "scale": 1, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "alarm_message": { + "type": "String", + "value": {} + }, + "basic_anti_flicker": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + } + }, + "status": { + "basic_flip": false, + "basic_osd": true, + "basic_private": true, + "motion_sensitivity": 1, + "basic_nightvision": 0, + "sd_storge": "30956544|30674944|281600", + "sd_status": 1, + "sd_format": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": false, + "sd_format_state": 0, + "ptz_control": 1, + "ptz_calibration": false, + "motion_switch": true, + "decibel_switch": false, + "decibel_sensitivity": 0, + "record_switch": true, + "record_mode": 1, + "siren_switch": false, + "motion_tracking": false, + "alarm_message": "**REDACTED**", + "basic_anti_flicker": 1 + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json new file mode 100644 index 00000000000..e98e38b21c8 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json @@ -0,0 +1,381 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "C9", + "category": "sp", + "product_id": "sdd5f5f2dl5wydjf", + "product_name": "Security Camera", + "online": true, + "sub": false, + "time_zone": "+11:00", + "active_time": "2025-03-13T07:28:30+00:00", + "create_time": "2025-03-13T07:28:30+00:00", + "update_time": "2025-03-13T07:28:30+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "min": 1, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "min": -20000, + "max": 200000, + "scale": 0, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "battery_report_cap": { + "type": "Integer", + "value": { + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "doorbell_active": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "wireless_electricity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "wireless_powermode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "doorbell_pic": { + "type": "Raw", + "value": {} + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "alarm_message": { + "type": "String", + "value": {} + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "initiative_message": { + "type": "Raw", + "value": {} + } + }, + "status": { + "basic_flip": false, + "basic_osd": true, + "motion_sensitivity": 1, + "basic_wdr": false, + "sd_storge": "30932992|3407872|27525120", + "sd_status": 1, + "sd_format": false, + "motion_record": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 0, + "ptz_control": 5, + "ipc_auto_siren": false, + "nightvision_mode": "auto", + "battery_report_cap": 1, + "ptz_calibration": false, + "motion_switch": true, + "doorbell_active": "", + "wireless_electricity": 80, + "wireless_powermode": 0, + "wireless_lowpower": 10, + "wireless_awake": false, + "record_switch": true, + "record_mode": 1, + "pir_switch": 2, + "doorbell_pic": "", + "siren_switch": false, + "basic_device_volume": 1, + "motion_tracking": true, + "device_restart": false, + "humanoid_filter": true, + "cruise_switch": false, + "cruise_mode": 0, + "alarm_message": "**REDACTED**", + "ipc_work_mode": 0, + "initiative_message": "" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json new file mode 100644 index 00000000000..94a8a7da26f --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json @@ -0,0 +1,137 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "jardin Fraises", + "category": "tdq", + "product_id": "1aegphq4yfd50e6b", + "product_name": "1-433", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-09-13T12:26:55+00:00", + "create_time": "2024-09-13T12:26:55+00:00", + "update_time": "2024-09-13T12:26:55+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC", + "switch_type": "button", + "remote_add": "", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json new file mode 100644 index 00000000000..3d7b24df7ec --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json @@ -0,0 +1,137 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Framboisiers", + "category": "tdq", + "product_id": "9htyiowaf5rtdhrv", + "product_name": "1-433", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-09-08T13:46:46+00:00", + "create_time": "2024-09-08T13:46:46+00:00", + "update_time": "2024-09-08T13:46:46+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC", + "switch_type": "button", + "remote_add": "", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json new file mode 100644 index 00000000000..844f8cd3742 --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json @@ -0,0 +1,246 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "4-433", + "category": "tdq", + "product_id": "cq1p0nt0a4rixnex", + "product_name": "4-433", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-12T16:57:13+00:00", + "create_time": "2025-06-12T16:57:13+00:00", + "update_time": "2025-06-12T16:57:13+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "switch_interlock": { + "type": "Raw", + "value": {} + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "switch_interlock": { + "type": "Raw", + "value": {} + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "switch_2": false, + "switch_3": false, + "switch_4": true, + "countdown_1": 0, + "countdown_2": 0, + "countdown_3": 0, + "countdown_4": 0, + "test_bit": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AQAjAwAeBAACBgAC", + "switch_type": "button", + "switch_interlock": "", + "remote_add": "AAA=", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json new file mode 100644 index 00000000000..e1f0865658f --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json @@ -0,0 +1,225 @@ +{ + "endpoint": "https://apigw.tuyain.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Seating side 6-ch Smart Switch ", + "category": "tdq", + "product_id": "nockvv2k39vbrxxk", + "product_name": "6 Switch Smart RetroFit Module", + "online": true, + "sub": false, + "time_zone": "+05:30", + "active_time": "2025-05-12T06:36:18+00:00", + "create_time": "2025-05-12T06:36:18+00:00", + "update_time": "2025-05-12T06:36:18+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": false, + "switch_2": true, + "switch_3": false, + "switch_4": true, + "switch_5": false, + "switch_6": true, + "countdown_1": 0, + "countdown_2": 0, + "countdown_3": 0, + "countdown_4": 0, + "countdown_5": 0, + "countdown_6": 0, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json new file mode 100644 index 00000000000..cc8d186513c --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json @@ -0,0 +1,167 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Socket3", + "category": "tdq", + "product_id": "pu8uhxhwcp3tgoz7", + "product_name": "Smart Plug +", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-16T18:48:20+00:00", + "create_time": "2025-01-16T18:48:20+00:00", + "update_time": "2025-01-16T18:48:20+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kW·h", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2381, + "test_bit": 2, + "fault": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json new file mode 100644 index 00000000000..54a8d78d92d --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Living room left", + "category": "tdq", + "product_id": "uoa3mayicscacseb", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-10-30T16:55:40+00:00", + "create_time": "2024-10-30T16:55:40+00:00", + "update_time": "2024-10-30T16:55:40+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json new file mode 100644 index 00000000000..ce8ab6c1d63 --- /dev/null +++ b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json @@ -0,0 +1,143 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Solar zijpad", + "category": "tyndj", + "product_id": "pyakuuoc", + "product_name": "Solar flood light App panel", + "online": false, + "sub": true, + "time_zone": "+08:00", + "active_time": "2023-03-08T13:24:06+00:00", + "create_time": "2023-03-08T13:24:06+00:00", + "update_time": "2023-03-08T13:24:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "countdown": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_save_energy": { + "type": "Boolean", + "value": {} + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto"] + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "countdown": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "switch_save_energy": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value": 10, + "scene_data": "", + "countdown": 0, + "switch_save_energy": false, + "battery_percentage": 0, + "device_mode": "manual", + "battery_state": "low" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json b/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json new file mode 100644 index 00000000000..7fedfb4826e --- /dev/null +++ b/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "ZigBee Gateway", + "category": "wfcon", + "product_id": "b25mh8sxawsgndck", + "product_name": "ZigBee Gateway", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-12-29T15:54:11+00:00", + "create_time": "2020-12-29T15:54:11+00:00", + "update_time": "2020-12-29T15:54:11+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wfcon_lieerjyy6l4ykjor.json b/tests/components/tuya/fixtures/wfcon_lieerjyy6l4ykjor.json new file mode 100644 index 00000000000..d30da9ff29b --- /dev/null +++ b/tests/components/tuya/fixtures/wfcon_lieerjyy6l4ykjor.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Zigbee Gateway", + "category": "wfcon", + "product_id": "lieerjyy6l4ykjor", + "product_name": "Zigbee Smart Gateway", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-09-10T17:09:13+00:00", + "create_time": "2023-09-10T17:09:13+00:00", + "update_time": "2023-09-10T17:09:13+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wg2_haclbl0qkqlf2qds.json b/tests/components/tuya/fixtures/wg2_haclbl0qkqlf2qds.json new file mode 100644 index 00000000000..7b5a5e2dece --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_haclbl0qkqlf2qds.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Home Gateway", + "category": "wg2", + "product_id": "haclbl0qkqlf2qds", + "product_name": "Multi-mode Gateway", + "online": false, + "sub": true, + "time_zone": "+03:00", + "active_time": "2025-08-03T10:30:30+00:00", + "create_time": "2025-08-03T10:30:30+00:00", + "update_time": "2025-08-03T10:30:30+00:00", + "function": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "switch_alarm_sound": false, + "muffling": false, + "master_state": "normal", + "factory_reset": false, + "alarm_active": "" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json new file mode 100644 index 00000000000..2fb4e9a6064 --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json @@ -0,0 +1,88 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "X5 Zigbee Gateway", + "category": "wg2", + "product_id": "nwxr8qcu4seltoro", + "product_name": "X5", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-14T10:19:21+00:00", + "create_time": "2025-07-14T10:19:21+00:00", + "update_time": "2025-07-14T10:19:21+00:00", + "function": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status_range": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "master_information": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status": { + "master_state": "normal", + "master_information": "", + "factory_reset": false, + "master_language": "chinese_simplified" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wg2_setmxeqgs63xwopm.json b/tests/components/tuya/fixtures/wg2_setmxeqgs63xwopm.json new file mode 100644 index 00000000000..54cc51114c5 --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_setmxeqgs63xwopm.json @@ -0,0 +1,68 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gateway", + "category": "wg2", + "product_id": "setmxeqgs63xwopm", + "product_name": "", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2021-11-26T13:33:58+00:00", + "create_time": "2021-11-26T13:33:58+00:00", + "update_time": "2021-11-26T13:33:58+00:00", + "function": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "switch_alarm_sound": false, + "master_state": "normal", + "factory_reset": false, + "alarm_active": "" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wg2_v7owd9tzcaninc36.json b/tests/components/tuya/fixtures/wg2_v7owd9tzcaninc36.json new file mode 100644 index 00000000000..dd8bbbbec2b --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_v7owd9tzcaninc36.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gateway2", + "category": "wg2", + "product_id": "v7owd9tzcaninc36", + "product_name": "Gateway", + "online": false, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-07-16T09:32:51+00:00", + "create_time": "2023-07-16T09:32:51+00:00", + "update_time": "2023-07-16T09:32:51+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_6kijc7nd.json b/tests/components/tuya/fixtures/wk_6kijc7nd.json new file mode 100644 index 00000000000..552de66c1d9 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_6kijc7nd.json @@ -0,0 +1,187 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Кабінет", + "category": "wk", + "product_id": "6kijc7nd", + "product_name": "Thermostat Tervix Pro Line ZigBee color", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-01-22T11:54:29+00:00", + "create_time": "2025-01-22T11:54:29+00:00", + "update_time": "2025-01-22T11:54:29+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "program"] + } + }, + "window_check": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 50, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 350, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "℃", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "program"] + } + }, + "window_check": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 50, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 350, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 995, + "scale": 1, + "step": 5 + } + }, + "window_state": { + "type": "Enum", + "value": { + "range": ["close", "open"] + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "℃", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + } + }, + "status": { + "switch": true, + "mode": "manual", + "window_check": false, + "frost": false, + "temp_set": 215, + "upper_temp": 450, + "temp_current": 195, + "window_state": "close", + "temp_correction": -2, + "humidity": 23, + "factory_reset": false, + "child_lock": false, + "sensor_choose": "all" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_aqoouq7x.json b/tests/components/tuya/fixtures/wk_aqoouq7x.json new file mode 100644 index 00000000000..3bf17e356ff --- /dev/null +++ b/tests/components/tuya/fixtures/wk_aqoouq7x.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Clima cucina", + "category": "wk", + "product_id": "aqoouq7x", + "product_name": "T7-Air conditioner thermostat\uff08ZIGBEE)", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-04-21T13:39:47+00:00", + "create_time": "2025-04-21T13:39:47+00:00", + "update_time": "2025-04-21T13:39:47+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "mode": "cold", + "temp_set": 25, + "temp_current": 27, + "level": "auto", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json b/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json new file mode 100644 index 00000000000..ed489927c1e --- /dev/null +++ b/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json @@ -0,0 +1,121 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Boiler Temperature Controller", + "category": "wk", + "product_id": "ccpwojhalfxryigz", + "product_name": "Intelligent temperature controller", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-24T19:20:54+00:00", + "create_time": "2024-01-24T19:20:54+00:00", + "update_time": "2024-01-24T19:20:54+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["e1", "e2", "e3"] + } + } + }, + "status": { + "switch": true, + "work_state": "hot", + "upper_temp": 585, + "temp_current": 575, + "lower_temp": 600, + "temp_correction": -8, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json new file mode 100644 index 00000000000..f7c28db1043 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json @@ -0,0 +1,186 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "WiFi Smart Gas Boiler Thermostat ", + "category": "wk", + "product_id": "fi6dne5tu4t1nm6j", + "product_name": "WiFi Smart Gas Boiler Thermostat ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-05T17:50:52+00:00", + "create_time": "2025-07-05T17:50:52+00:00", + "update_time": "2025-07-05T17:50:52+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 150, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 140, + "scale": 1, + "step": 5 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "factory_reset": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["battery_temp_fault"] + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 150, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 140, + "scale": 1, + "step": 5 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "factory_reset": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": true, + "mode": "auto", + "temp_set": 220, + "temp_current": 249, + "temp_correction": -15, + "fault": 0, + "upper_temp": 350, + "lower_temp": 50, + "battery_percentage": 100, + "child_lock": false, + "frost": false, + "factory_reset": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_gc1bxoq2hafxpa35.json b/tests/components/tuya/fixtures/wk_gc1bxoq2hafxpa35.json new file mode 100644 index 00000000000..23a8607f2d1 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_gc1bxoq2hafxpa35.json @@ -0,0 +1,110 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u041f\u043e\u043b\u043e\u0442\u0435\u043d\u0446\u043e\u0441\u0443\u0448\u0438\u0442\u0435\u043b\u044c", + "category": "wk", + "product_id": "gc1bxoq2hafxpa35", + "product_name": "ET-44W", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2025-02-08T21:08:00+00:00", + "create_time": "2025-02-08T21:08:00+00:00", + "update_time": "2025-02-08T21:08:00+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["holiday"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 400, + "scale": 1, + "step": 5 + } + }, + "temp_set_f": { + "type": "Integer", + "value": { + "unit": "\u2109", + "min": 410, + "max": 1040, + "scale": 1, + "step": 10 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["holiday"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 400, + "scale": 1, + "step": 5 + } + }, + "temp_set_f": { + "type": "Integer", + "value": { + "unit": "\u2109", + "min": 410, + "max": 1040, + "scale": 1, + "step": 10 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 450, + "scale": 1, + "step": 5 + } + }, + "temp_current_f": { + "type": "Integer", + "value": { + "unit": "\u2109", + "min": 320, + "max": 1130, + "scale": 1, + "step": 10 + } + } + }, + "status": { + "switch": false, + "mode": "hold", + "temp_set": 50, + "temp_set_f": 410, + "temp_current": 253, + "temp_current_f": 320 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json b/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json new file mode 100644 index 00000000000..0841f77ca2c --- /dev/null +++ b/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json @@ -0,0 +1,195 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "smart thermostats", + "category": "wk", + "product_id": "gogb05wrtredz3bs", + "product_name": "smart thermostats", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-23T13:07:21+00:00", + "create_time": "2025-01-23T13:07:21+00:00", + "update_time": "2025-01-23T13:07:21+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 30, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "摄氏度", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 30, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 900, + "scale": 1, + "step": 5 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "摄氏度", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "valve_state": { + "type": "Enum", + "value": { + "range": ["open", "close"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["e1", "e2", "e3"] + } + } + }, + "status": { + "switch": false, + "mode": "manual", + "frost": true, + "temp_set": 12, + "upper_temp": 30, + "temp_current": 215, + "lower_temp": 5, + "temp_correction": -2, + "valve_state": "close", + "factory_reset": false, + "child_lock": false, + "sensor_choose": "in", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json b/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json new file mode 100644 index 00000000000..efe02c633f3 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json @@ -0,0 +1,74 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Term - Prizemi", + "category": "wk", + "product_id": "y5obtqhuztqsf2mj", + "product_name": "Smart", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-02-22T10:25:25+00:00", + "create_time": "2025-02-22T10:25:25+00:00", + "update_time": "2025-02-22T10:25:25+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 900, + "scale": 1, + "step": 1 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": true, + "temp_set": 230, + "temp_current": 230, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json b/tests/components/tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json new file mode 100644 index 00000000000..78afaebc51f --- /dev/null +++ b/tests/components/tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json @@ -0,0 +1,227 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "pid_relay_2", + "category": "wkcz", + "product_id": "gc4b1mdw7kebtuyz", + "product_name": "4-TH", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-14T16:04:37+00:00", + "create_time": "2025-01-14T16:04:37+00:00", + "update_time": "2025-01-14T16:04:37+00:00", + "function": { + "switch_all": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -90, + "max": 90, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "hum_calibration": { + "type": "Integer", + "value": { + "unit": "%", + "min": -10, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_all": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -90, + "max": 90, + "scale": 1, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["cooling_fault", "heating_fault", "temp_dif_fault"] + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "hum_calibration": { + "type": "Integer", + "value": { + "unit": "%", + "min": -10, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_all": false, + "switch_1": false, + "switch_2": false, + "temp_current": 170, + "upper_temp": 0, + "lower_temp": 0, + "countdown_1": 0, + "temp_correction": 0, + "fault": 0, + "humidity_value": 38, + "maxhum_set": 0, + "minihum_set": 0, + "hum_calibration": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wnykq_npbbca46yiug8ysk.json b/tests/components/tuya/fixtures/wnykq_npbbca46yiug8ysk.json new file mode 100644 index 00000000000..2ea3099bc2b --- /dev/null +++ b/tests/components/tuya/fixtures/wnykq_npbbca46yiug8ysk.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bedroom IR", + "category": "wnykq", + "product_id": "npbbca46yiug8ysk", + "product_name": "Smart IR", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-08-10T17:32:31+00:00", + "create_time": "2023-08-10T17:32:31+00:00", + "update_time": "2023-08-10T17:32:31+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json b/tests/components/tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json new file mode 100644 index 00000000000..f2ceac8e898 --- /dev/null +++ b/tests/components/tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart IR", + "category": "wnykq", + "product_id": "rqhxdyusjrwxyff6", + "product_name": "Smart IR", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2024-07-18T12:07:37+00:00", + "create_time": "2024-07-18T12:07:37+00:00", + "update_time": "2024-07-18T12:07:37+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json new file mode 100644 index 00000000000..51367039d9f --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json @@ -0,0 +1,155 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "NP DownStairs North", + "category": "wsdcg", + "product_id": "g2y6z3p3ja2qhyav", + "product_name": "\u6e29\u6e7f\u5ea6\u4f20\u611f\u5668wifi", + "online": true, + "sub": false, + "time_zone": "+10:30", + "active_time": "2023-12-22T03:38:57+00:00", + "create_time": "2023-12-22T03:38:57+00:00", + "update_time": "2023-12-22T03:38:57+00:00", + "function": { + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + } + }, + "status": { + "va_temperature": 185, + "va_humidity": 47, + "battery_percentage": 0, + "maxtemp_set": 600, + "minitemp_set": -100, + "maxhum_set": 100, + "minihum_set": 0, + "temp_alarm": "cancel", + "hum_alarm": "cancel" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_iq4ygaai.json b/tests/components/tuya/fixtures/wsdcg_iq4ygaai.json new file mode 100644 index 00000000000..d76dae842fa --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_iq4ygaai.json @@ -0,0 +1,45 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bassin", + "category": "wsdcg", + "product_id": "iq4ygaai", + "product_name": "Temperature Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T12:20:32+00:00", + "create_time": "2025-07-19T12:20:32+00:00", + "update_time": "2025-07-19T12:20:32+00:00", + "function": {}, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 1990, + "scale": 1, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 217, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_iv7hudlj.json b/tests/components/tuya/fixtures/wsdcg_iv7hudlj.json new file mode 100644 index 00000000000..b96cb26a1a9 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_iv7hudlj.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Basement temperature", + "category": "wsdcg", + "product_id": "iv7hudlj", + "product_name": "Bluetooth Temperature Humidity Sensor", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-10-13T10:07:37+00:00", + "create_time": "2024-10-13T10:07:37+00:00", + "update_time": "2024-10-13T10:07:37+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + } + }, + "status": { + "va_temperature": 162, + "va_humidity": 47, + "battery_percentage": 100, + "temp_unit_convert": "c", + "maxtemp_set": 400, + "minitemp_set": 200, + "maxhum_set": 85, + "minihum_set": 20, + "temp_alarm": "loweralarm", + "hum_alarm": "cancel" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json b/tests/components/tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json new file mode 100644 index 00000000000..023bcc269fa --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json @@ -0,0 +1,66 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "IFS-STD002", + "category": "wsdcg", + "product_id": "krlcihrpzpc8olw9", + "product_name": "IFS-STD002", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2025-06-28T13:33:51+00:00", + "create_time": "2025-06-28T13:33:51+00:00", + "update_time": "2025-06-28T13:33:51+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "va_temperature": 289, + "va_humidity": 61, + "battery_state": "high", + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json b/tests/components/tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json new file mode 100644 index 00000000000..5c52cf2796e --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json @@ -0,0 +1,66 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Greenhouse", + "category": "wsdcg", + "product_id": "lf36y5nwb8jkxwgg", + "product_name": "T & H Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-08-14T13:59:25+00:00", + "create_time": "2023-08-14T13:59:25+00:00", + "update_time": "2023-08-14T13:59:25+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 99, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "va_temperature": 322, + "va_humidity": 53, + "battery_state": "middle", + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json b/tests/components/tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json new file mode 100644 index 00000000000..1eb84adfc31 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Humy bain", + "category": "wsdcg", + "product_id": "vtA4pDd6PLUZzXgZ", + "product_name": "Temperature and humidity sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T12:04:21+00:00", + "create_time": "2025-07-19T12:04:21+00:00", + "update_time": "2025-07-19T12:04:21+00:00", + "function": {}, + "status_range": { + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -20, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "va_battery": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_humidity": 63, + "va_battery": 100, + "va_temperature": 20 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_xr3htd96.json b/tests/components/tuya/fixtures/wsdcg_xr3htd96.json new file mode 100644 index 00000000000..13bdff60b33 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_xr3htd96.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Humy toilettes RDC", + "category": "wsdcg", + "product_id": "xr3htd96", + "product_name": "Temperature Humidity Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-08-01T09:33:45+00:00", + "create_time": "2025-08-01T09:33:45+00:00", + "update_time": "2025-08-01T09:33:45+00:00", + "function": {}, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 206, + "va_humidity": 618, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json b/tests/components/tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json new file mode 100644 index 00000000000..1186bfb4572 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json @@ -0,0 +1,259 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": false, + "disabled_by": null, + "disabled_polling": false, + "name": "WiFi Temperature & Humidity Sensor", + "category": "wsdcg", + "product_id": "yqiqbaldtr0i7mru", + "product_name": "WiFi Temperature & Humidity Sensor", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2023-11-27T03:59:48+00:00", + "create_time": "2023-11-27T03:59:48+00:00", + "update_time": "2023-11-27T03:59:48+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "hum_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "temp_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 20, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 20, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "temp_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "hum_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "temp_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 20, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 20, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 251, + "va_humidity": 12, + "battery_state": "high", + "battery_percentage": 100, + "temp_unit_convert": "c", + "maxtemp_set": 390, + "minitemp_set": 0, + "maxhum_set": 60, + "minihum_set": 20, + "temp_alarm": "cancel", + "hum_alarm": "loweralarm", + "temp_periodic_report": 60, + "hum_periodic_report": 120, + "temp_sensitivity": 6, + "hum_sensitivity": 6 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wxkg_ja5osu5g.json b/tests/components/tuya/fixtures/wxkg_ja5osu5g.json new file mode 100644 index 00000000000..d8e841fc599 --- /dev/null +++ b/tests/components/tuya/fixtures/wxkg_ja5osu5g.json @@ -0,0 +1,64 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bouton tempo ext\u00e9rieur", + "category": "wxkg", + "product_id": "ja5osu5g", + "product_name": "ZC-YED-\u4e00\u952e\u65e0\u7ebf\u5f00\u5173", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-25T15:49:27+00:00", + "create_time": "2025-07-25T15:49:27+00:00", + "update_time": "2025-07-25T15:49:27+00:00", + "function": { + "mode": { + "type": "Enum", + "value": { + "range": ["remote_control", "wireless_switch"] + } + }, + "scene_preset": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_mode1": { + "type": "Enum", + "value": { + "range": ["click", "double_click", "press"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["remote_control", "wireless_switch"] + } + }, + "scene_preset": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_mode1": "press", + "battery_percentage": 100, + "mode": "wireless_switch", + "scene_preset": "800003e80384005a03e803e8810003e8006400b403e803e8820001900320010e03e803e883010190000000f000fa00fa" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wxkg_l8yaz4um5b3pwyvf.json b/tests/components/tuya/fixtures/wxkg_l8yaz4um5b3pwyvf.json new file mode 100644 index 00000000000..376276099cc --- /dev/null +++ b/tests/components/tuya/fixtures/wxkg_l8yaz4um5b3pwyvf.json @@ -0,0 +1,50 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "44", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom Smart Switch", + "model": "LKWSW201", + "category": "wxkg", + "product_id": "l8yaz4um5b3pwyvf", + "product_name": "Wireless Switch", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-01-05T20:12:39+00:00", + "create_time": "2023-01-05T20:12:39+00:00", + "update_time": "2023-05-30T17:17:47+00:00", + "function": {}, + "status_range": { + "switch_mode1": { + "type": "Enum", + "value": { + "range": ["click", "press"] + } + }, + "switch_mode2": { + "type": "Enum", + "value": { + "range": ["click", "press"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_mode1": "click", + "switch_mode2": "click", + "battery_percentage": 100 + } +} diff --git a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json new file mode 100644 index 00000000000..5fd511c7506 --- /dev/null +++ b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "DOLCECLIMA 10 HP WIFI", + "category": "ydkt", + "product_id": "jevroj5aguwdbs2e", + "product_name": "DOLCECLIMA 10 HP WIFI", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-09T18:39:25+00:00", + "create_time": "2025-07-09T18:39:25+00:00", + "update_time": "2025-07-09T18:39:25+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json b/tests/components/tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json new file mode 100644 index 00000000000..1f517f9b775 --- /dev/null +++ b/tests/components/tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json @@ -0,0 +1,63 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Pond", + "category": "ygsb", + "product_id": "l6ax0u6jwbz82atk", + "product_name": "\u6c34\u6cf5", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-07T22:22:44+00:00", + "create_time": "2025-06-07T22:22:44+00:00", + "update_time": "2025-06-07T22:22:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_flow": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "pause": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_flow": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "pause": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "water_flow": 0, + "pause": true + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ykq_bngwdjsr.json b/tests/components/tuya/fixtures/ykq_bngwdjsr.json new file mode 100644 index 00000000000..085dd52b6cb --- /dev/null +++ b/tests/components/tuya/fixtures/ykq_bngwdjsr.json @@ -0,0 +1,70 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "T\u00e9l\u00e9commande lumi\u00e8res ZigBee", + "category": "ykq", + "product_id": "bngwdjsr", + "product_name": "Remote controller", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:36:45+00:00", + "create_time": "2025-07-19T11:36:45+00:00", + "update_time": "2025-07-19T11:36:45+00:00", + "function": { + "switch_controller": { + "type": "Boolean", + "value": {} + }, + "mode_controller": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "bright_controller": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_controller": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "scene_controller": { + "type": "Enum", + "value": { + "range": ["scene_1", "scene_2", "scene_3", "scene_4"] + } + } + }, + "status": { + "battery_percentage": 100, + "scene_controller": "scene_1" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_arywmw6h6vesoz5t.json b/tests/components/tuya/fixtures/ywbj_arywmw6h6vesoz5t.json new file mode 100644 index 00000000000..eee71d0c45a --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_arywmw6h6vesoz5t.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Rauchmelder Drucker", + "category": "ywbj", + "product_id": "arywmw6h6vesoz5t", + "product_name": "Smoke Alarm ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:45:57+00:00", + "create_time": "2025-03-15T13:45:57+00:00", + "update_time": "2025-03-15T13:45:57+00:00", + "function": {}, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "smoke_sensor_status": "normal", + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_cjlutkuuvxnie17o.json b/tests/components/tuya/fixtures/ywbj_cjlutkuuvxnie17o.json new file mode 100644 index 00000000000..6495e99e4d3 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_cjlutkuuvxnie17o.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Rauchmelder Alexsandro ", + "category": "ywbj", + "product_id": "cjlutkuuvxnie17o", + "product_name": "Smoke Alarm", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-07T18:50:02+00:00", + "create_time": "2023-01-07T18:50:02+00:00", + "update_time": "2023-01-07T18:50:02+00:00", + "function": {}, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "smoke_sensor_status": "normal", + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json b/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json new file mode 100644 index 00000000000..c39835694c7 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json @@ -0,0 +1,63 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": " Smoke detector upstairs ", + "category": "ywbj", + "product_id": "gf9dejhmzffgdyfj", + "product_name": "Smart Smoke Alarm", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-11-09T13:21:37+00:00", + "create_time": "2021-11-09T13:21:37+00:00", + "update_time": "2021-11-09T13:21:37+00:00", + "function": { + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "lifecycle": { + "type": "Boolean", + "value": {} + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "smoke_sensor_status": "normal", + "lifecycle": true, + "battery_state": "low", + "battery_percentage": 16, + "muffling": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_kscbebaf3s1eogvt.json b/tests/components/tuya/fixtures/ywbj_kscbebaf3s1eogvt.json new file mode 100644 index 00000000000..00e5db9dc94 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_kscbebaf3s1eogvt.json @@ -0,0 +1,51 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "WIFI Smoke alarm", + "category": "ywbj", + "product_id": "kscbebaf3s1eogvt", + "product_name": "WIFI Smoke alarm", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-08-16T13:20:39+00:00", + "create_time": "2023-08-16T13:20:39+00:00", + "update_time": "2023-08-16T13:20:39+00:00", + "function": { + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "smoke_sensor_status": "normal", + "battery_percentage": 90, + "muffling": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_rccxox8p.json b/tests/components/tuya/fixtures/ywbj_rccxox8p.json new file mode 100644 index 00000000000..45a5e8697f2 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_rccxox8p.json @@ -0,0 +1,68 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smoke Alarm", + "category": "ywbj", + "product_id": "rccxox8p", + "product_name": "Smoke Alarm", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2024-08-05T13:47:04+00:00", + "create_time": "2024-08-05T13:47:04+00:00", + "update_time": "2024-08-05T13:47:04+00:00", + "function": { + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "smoke_sensor_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "smoke_sensor_status": "normal", + "smoke_sensor_value": 0, + "battery_state": "low", + "battery_percentage": 100, + "muffling": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json new file mode 100644 index 00000000000..31d26fbb715 --- /dev/null +++ b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json @@ -0,0 +1,146 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Rainwater Tank Level", + "category": "ywcgq", + "product_id": "h8lvyoahr6s6aybf", + "product_name": "Tank A Level", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-31T09:55:19+00:00", + "create_time": "2025-05-31T09:55:19+00:00", + "update_time": "2025-05-31T09:55:19+00:00", + "function": { + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + } + }, + "status_range": { + "liquid_state": { + "type": "Enum", + "value": { + "range": ["normal", "lower_alarm", "upper_alarm"] + } + }, + "liquid_depth": { + "type": "Integer", + "value": { + "unit": "m", + "min": 0, + "max": 10000, + "scale": 3, + "step": 1 + } + }, + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + }, + "liquid_level_percent": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "liquid_state": "normal", + "liquid_depth": 455, + "max_set": 90, + "mini_set": 10, + "upper_switch": false, + "installation_height": 1350, + "liquid_depth_max": 100, + "liquid_level_percent": 36 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json new file mode 100644 index 00000000000..200790afedb --- /dev/null +++ b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://openapi.tuyaus.com", + "auth_type": 0, + "country_code": "1", + "app_type": "tuyaSmart", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "House Water Level", + "model": "EPT_Ultrasonic level sensor", + "category": "ywcgq", + "product_id": "wtzwyhkev3b4ubns", + "product_name": "Tank A Level", + "online": true, + "sub": false, + "time_zone": "-06:00", + "active_time": "2023-11-02T22:48:03+00:00", + "create_time": "2023-11-02T22:48:03+00:00", + "update_time": "2023-11-09T13:32:38+00:00", + "function": { + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 200, + "max": 2500, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2400, + "scale": 3, + "step": 1 + } + } + }, + "status_range": { + "liquid_state": { + "type": "Enum", + "value": { + "range": ["normal", "lower_alarm", "upper_alarm"] + } + }, + "liquid_depth": { + "type": "Integer", + "value": { + "unit": "m", + "min": 0, + "max": 10000, + "scale": 2, + "step": 1 + } + }, + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 200, + "max": 2500, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2400, + "scale": 3, + "step": 1 + } + }, + "liquid_level_percent": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "liquid_state": "upper_alarm", + "liquid_depth": 42, + "max_set": 100, + "mini_set": 0, + "installation_height": 560, + "liquid_depth_max": 100, + "liquid_level_percent": 100 + } +} diff --git a/tests/components/tuya/fixtures/zjq_nkkl7uzv.json b/tests/components/tuya/fixtures/zjq_nkkl7uzv.json new file mode 100644 index 00000000000..043db64dc77 --- /dev/null +++ b/tests/components/tuya/fixtures/zjq_nkkl7uzv.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Zigby r\u00e9p\u00e9teur ", + "category": "zjq", + "product_id": "nkkl7uzv", + "product_name": "Zigbee Repeater", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-25T17:19:41+00:00", + "create_time": "2025-07-25T17:19:41+00:00", + "update_time": "2025-07-25T17:19:41+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json new file mode 100644 index 00000000000..92f507abaca --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json @@ -0,0 +1,216 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "XOCA-DAC212XC V2-S1", + "category": "zndb", + "product_id": "4ggkyflayu1h1ho9", + "product_name": "XOCA-DAC212XC V2-S1", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-07-07T10:32:35+00:00", + "create_time": "2025-07-07T10:32:35+00:00", + "update_time": "2025-07-07T10:32:35+00:00", + "function": { + "frozen_time_set": { + "type": "Json", + "value": {} + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_2": { + "type": "Json", + "value": {} + }, + "event_clear": { + "type": "Boolean", + "value": {} + }, + "price_set": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Json", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "terminal_alarm", + "cover_alarm", + "credit_alarm", + "no_balance_alarm", + "battery_alarm", + "meter_hardware_alarm", + "overdraft_unlim", + "arrear_outage", + "overdraft_use", + "pf_abnormal", + "ov_pwr" + ] + } + }, + "frozen_time_set": { + "type": "Json", + "value": {} + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_2": { + "type": "Json", + "value": {} + }, + "meter_id": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "event_clear": { + "type": "Boolean", + "value": {} + }, + "forward_energy_t1": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "forward_energy_t2": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "forward_energy_t3": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "forward_energy_t4": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "price_set": { + "type": "Raw", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["offline", "online"] + } + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "forward_energy_total": 120, + "reverse_energy_total": 80, + "phase_a": { + "electricCurrent": 599.552, + "power": 6.912, + "voltage": 52.7 + }, + "fault": 0, + "frozen_time_set": { + "day": 158, + "hour": 233 + }, + "switch_prepayment": false, + "clear_energy": false, + "switch": true, + "alarm_set_2": [], + "meter_id": "", + "event_clear": false, + "forward_energy_t1": 0, + "forward_energy_t2": 0, + "forward_energy_t3": 0, + "forward_energy_t4": 0, + "price_set": "", + "online_state": "offline", + "supply_frequency": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json b/tests/components/tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json new file mode 100644 index 00000000000..01401e16dd3 --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Production", + "category": "zndb", + "product_id": "v5jlnn5hwyffkhp3", + "product_name": "Smart Meter", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-18T08:57:59+00:00", + "create_time": "2025-04-18T08:57:59+00:00", + "update_time": "2025-04-18T08:57:59+00:00", + "function": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "total_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": -99999999, + "max": 99999999, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "forward_energy_total": 152021, + "reverse_energy_total": 0, + "total_power": 23146 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json new file mode 100644 index 00000000000..caf9074d277 --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Meter", + "category": "zndb", + "product_id": "ze8faryrxr0glqnn", + "product_name": "PJ2101A 1P WiFi Smart Meter ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-24T11:22:33+00:00", + "create_time": "2024-08-24T11:22:33+00:00", + "update_time": "2024-08-24T11:22:33+00:00", + "function": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "energy_month": { + "type": "raw", + "value": {} + }, + "energy_daily": { + "type": "raw", + "value": {} + } + }, + "status_range": { + "energy_month": { + "type": "raw", + "value": {} + }, + "energy_daily": { + "type": "raw", + "value": {} + }, + "phase_a": { + "type": "raw", + "value": {} + }, + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "energy_month": "GAkYCQAAANQ=", + "energy_daily": "", + "phase_a": "CSIAFfQABKE=" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/znnbq_6b3pbbuqbfabhfiq.json b/tests/components/tuya/fixtures/znnbq_6b3pbbuqbfabhfiq.json new file mode 100644 index 00000000000..597721599e3 --- /dev/null +++ b/tests/components/tuya/fixtures/znnbq_6b3pbbuqbfabhfiq.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Wi-Fi solar grid micro inverter \uff08GT\uff09", + "category": "znnbq", + "product_id": "6b3pbbuqbfabhfiq", + "product_name": "Wi-Fi solar grid micro inverter \uff08GT\uff09", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-08-18T16:19:32+00:00", + "create_time": "2025-08-18T16:19:32+00:00", + "update_time": "2025-08-18T16:19:32+00:00", + "function": {}, + "status_range": { + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "power_total": { + "type": "Integer", + "value": { + "unit": "kW", + "min": 0, + "max": 900000, + "scale": 3, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 2000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "reverse_energy_total": 19, + "power_total": 0, + "temp_current": 219 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/znrb_db81ge24jctwx8lo.json b/tests/components/tuya/fixtures/znrb_db81ge24jctwx8lo.json new file mode 100644 index 00000000000..6e379eff375 --- /dev/null +++ b/tests/components/tuya/fixtures/znrb_db81ge24jctwx8lo.json @@ -0,0 +1,172 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Hot Water Heat Pump", + "category": "znrb", + "product_id": "db81ge24jctwx8lo", + "product_name": "Heat Pump", + "online": true, + "sub": false, + "time_zone": "+11:00", + "active_time": "2025-01-08T23:48:22+00:00", + "create_time": "2025-01-08T23:48:22+00:00", + "update_time": "2025-01-08T23:48:22+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 15, + "max": 75, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "defrost": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 15, + "max": 75, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "defrost": { + "type": "Boolean", + "value": {} + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "P", + "min": -500, + "max": 500, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -30, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "power_consumption": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 100000, + "scale": 2, + "step": 1 + } + }, + "compressor_strength": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -50, + "max": 140, + "scale": 0, + "step": 1 + } + }, + "temp_top": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -58, + "max": 312, + "scale": 0, + "step": 1 + } + }, + "temp_bottom": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -50, + "max": 150, + "scale": 0, + "step": 1 + } + }, + "compressor_state": { + "type": "Boolean", + "value": {} + }, + "four_valve_state": { + "type": "Boolean", + "value": {} + }, + "draught_fan_state": { + "type": "Boolean", + "value": {} + }, + "pump_state": { + "type": "Boolean", + "value": {} + }, + "ele_heating_state": { + "type": "Boolean", + "value": {} + }, + "defrost_state": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "temp_set": 60, + "temp_unit_convert": "c", + "defrost": false, + "countdown_left": 300, + "temp_current": 42, + "power_consumption": 0, + "compressor_strength": 23, + "temp_top": 21, + "temp_bottom": -50, + "compressor_state": false, + "four_valve_state": false, + "draught_fan_state": false, + "pump_state": true, + "ele_heating_state": false, + "defrost_state": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zwjcy_myd45weu.json b/tests/components/tuya/fixtures/zwjcy_myd45weu.json new file mode 100644 index 00000000000..dc6c0510ffc --- /dev/null +++ b/tests/components/tuya/fixtures/zwjcy_myd45weu.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Patates", + "category": "zwjcy", + "product_id": "myd45weu", + "product_name": "Soil sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T12:12:41+00:00", + "create_time": "2025-07-19T12:12:41+00:00", + "update_time": "2025-07-19T12:12:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": -30, + "max": 70, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "humidity": 97, + "temp_current": 22, + "temp_unit_convert": "c", + "battery_state": "low", + "battery_percentage": 20 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..337b579c7da --- /dev/null +++ b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[alarm_control_panel.multifunction_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.multifunction_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.2pxfek1jjrtctiyglammaster_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[alarm_control_panel.multifunction_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Multifunction alarm', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.multifunction_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..c2f246fb9e9 --- /dev/null +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1519 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[binary_sensor.aqi_safety-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.aqi_safety', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Safety', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.iks13mcaiyie3rryjb2occo2_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.aqi_safety-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'AQI Safety', + }), + 'context': , + 'entity_id': 'binary_sensor.aqi_safety', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.boite_aux_lettres_arriere_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boite_aux_lettres_arriere_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.7obpyhy8scmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.boite_aux_lettres_arriere_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Boîte aux lettres - arrière Door', + }), + 'context': , + 'entity_id': 'binary_sensor.boite_aux_lettres_arriere_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dehumidifer_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscdefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_tank_full-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dehumidifer_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.ifzgvpgoodrfw2aksctankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_defrost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dehumidifier_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.2myxayqtud9aqbizscdefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_tank_full-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dehumidifier_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.2myxayqtud9aqbizsctankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_wet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dehumidifier_wet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wet', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wet', + 'unique_id': 'tuya.2myxayqtud9aqbizscwet', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_wet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Wet', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_wet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.door_garage_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.door_garage_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bFFsO8HimyAJGIj7scmswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.door_garage_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Door Garage Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door_garage_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fenetre_cuisine_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.yuanswy6scmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Fenêtre cuisine Door', + }), + 'context': , + 'entity_id': 'binary_sensor.fenetre_cuisine_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fenetre_cuisine_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.yuanswy6scmtemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Fenêtre cuisine Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.fenetre_cuisine_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.garage_contact_sensor_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_contact_sensor_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.3uqk1csjqplf3uxqscmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.garage_contact_sensor_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Garage Contact Sensor Door', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_contact_sensor_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.gas_sensor_gas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.cwwk68dyfsh2eqi4jbqrgas_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.gas_sensor_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'context': , + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.gateway_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mpowx36sgqexmtes2gwmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.home_gateway_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.home_gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.sdq2flqkq0lblcah2gwmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.home_gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Home Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.home_gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.human_presence_office_occupancy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.human_presence_office_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.kxwleaa2sphpresence_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.human_presence_office_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Human presence Office Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.human_presence_office_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.s3zzjdcfrippir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Motion sensor lidl zigbee Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.s3zzjdcfriptemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Motion sensor lidl zigbee Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.pir_outside_stairs_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.pir_outside_stairs_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.zoytcemodrn39zqwrippir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.pir_outside_stairs_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'PIR outside stairs Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.pir_outside_stairs_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.rat_trap_hedge_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.hkm4px9ohzozxma3rippir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'rat trap hedge Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.rat_trap_hedge_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.rat_trap_hedge_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.hkm4px9ohzozxma3riptemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'rat trap hedge Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.rat_trap_hedge_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_alexsandro_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.rauchmelder_alexsandro_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.o71einxvuuktuljcjbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_alexsandro_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Rauchmelder Alexsandro Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.rauchmelder_alexsandro_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_drucker_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.rauchmelder_drucker_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.t5zosev6h6wmwyrajbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_drucker_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Rauchmelder Drucker Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.rauchmelder_drucker_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smart_thermostats_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smart_thermostats_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve', + 'unique_id': 'tuya.sb3zdertrw50bgogkwvalve_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smart_thermostats_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Valve', + }), + 'context': , + 'entity_id': 'binary_sensor.smart_thermostats_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smogo_safety-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smogo_safety', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Safety', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.swhtzki3qrz5ydchjbocco_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smogo_safety-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'Smogo Safety', + }), + 'context': , + 'entity_id': 'binary_sensor.smogo_safety', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smoke_alarm_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smoke_alarm_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.p8xoxccrjbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smoke_alarm_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Smoke Alarm Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_alarm_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smoke_detector_upstairs_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smoke_detector_upstairs_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smoke_detector_upstairs_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': ' Smoke detector upstairs Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector_upstairs_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.soil_moisture_sensor_1_occupancy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.soil_moisture_sensor_1_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.oqyhsaqwsphpresence_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.soil_moisture_sensor_1_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Soil moisture sensor #1 Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.soil_moisture_sensor_1_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.steel_cage_door_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.steel_cage_door_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.gvxxy4jitzltz5xhscmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.steel_cage_door_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Steel cage door Door', + }), + 'context': , + 'entity_id': 'binary_sensor.steel_cage_door_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.tournesol_moisture-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.tournesol_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.codvtvgtjswatersensor_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.tournesol_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Tournesol Moisture', + }), + 'context': , + 'entity_id': 'binary_sensor.tournesol_moisture', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.wifi_smoke_alarm_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.wifi_smoke_alarm_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.tvgoe1s3fabebcskjbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.wifi_smoke_alarm_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'WIFI Smoke alarm Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.wifi_smoke_alarm_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.x5_zigbee_gateway_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.orotles4ucq8rxwn2gwmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.x5_zigbee_gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'X5 Zigbee Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_button.ambr b/tests/components/tuya/snapshots/test_button.ambr new file mode 100644 index 00000000000..6103a07d08d --- /dev/null +++ b/tests/components/tuya/snapshots/test_button.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[button.v20_reset_duster_cloth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_duster_cloth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset duster cloth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_duster_cloth', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_duster_cloth', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_duster_cloth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset duster cloth', + }), + 'context': , + 'entity_id': 'button.v20_reset_duster_cloth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_edge_brush-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_edge_brush', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset edge brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_edge_brush', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_edge_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_edge_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset edge brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_edge_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filter', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset filter', + }), + 'context': , + 'entity_id': 'button.v20_reset_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_map-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset map', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_map', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_map-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset map', + }), + 'context': , + 'entity_id': 'button.v20_reset_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_roll_brush-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_roll_brush', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset roll brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_roll_brush', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_roll_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[button.v20_reset_roll_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset roll brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_roll_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr new file mode 100644 index 00000000000..df6ea532d83 --- /dev/null +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -0,0 +1,269 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[camera.burocam-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.burocam', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.svjjuwykgijjedurps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.burocam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.burocam?token=1', + 'friendly_name': 'Bürocam', + 'model_name': 'LSC PTZ Camera', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.burocam', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- +# name: test_platform_setup_and_discovery[camera.c9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.c9', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.fjdyw5ld2f5f5ddsps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.c9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.c9?token=1', + 'friendly_name': 'C9', + 'model_name': 'Security Camera', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.c9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- +# name: test_platform_setup_and_discovery[camera.cam_garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_garage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mgcpxpmovasazerdps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.cam_garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_garage?token=1', + 'friendly_name': 'CAM GARAGE', + 'model_name': 'Indoor camera ', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[camera.cam_porch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_porch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.cam_porch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_porch?token=1', + 'friendly_name': 'CAM PORCH', + 'model_name': 'Indoor cam Pan/Tilt ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_porch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[camera.garage_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.garage_camera', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.53fnjncm3jywuaznps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.garage_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.garage_camera?token=1', + 'friendly_name': 'Garage Camera', + 'model_name': 'Smart Camera ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.garage_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr new file mode 100644 index 00000000000..7687c68ad31 --- /dev/null +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -0,0 +1,781 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[climate.air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + '1', + '2', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mvsdcwtskkezlnw5tk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'fan_mode': 1, + 'fan_modes': list([ + '1', + '2', + ]), + 'friendly_name': 'Air Conditioner', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[climate.boiler_temperature_controller-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.boiler_temperature_controller', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.zgiyrxflahjowpcckw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.boiler_temperature_controller-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 57.5, + 'friendly_name': 'Boiler Temperature Controller', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.boiler_temperature_controller', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_platform_setup_and_discovery[climate.clima_cucina-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.clima_cucina', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.x7quooqakw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.clima_cucina-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27.0, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'friendly_name': 'Clima cucina', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 25.0, + }), + 'context': , + 'entity_id': 'climate.clima_cucina', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[climate.kabinet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 95.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'program', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.kabinet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.dn7cjik6kw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.kabinet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.5, + 'friendly_name': 'Кабінет', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 95.0, + 'min_temp': 5.0, + 'preset_mode': None, + 'preset_modes': list([ + 'program', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.kabinet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_platform_setup_and_discovery[climate.master_bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 88.0, + 'min_temp': 16.0, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.master_bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.g1fmm26qhhrimmbitk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.master_bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 0, + 'current_temperature': 26.0, + 'friendly_name': 'Master Bedroom AC', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 88.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 75.0, + }), + 'context': , + 'entity_id': 'climate.master_bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_platform_setup_and_discovery[climate.mr_pure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mr_pure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.gnqwzcph94wj2sl5nq', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.mr_pure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Mr. Pure', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.mr_pure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[climate.polotentsosushitel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'holiday', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.polotentsosushitel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.53apxfah2qoxb1cgkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.polotentsosushitel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25.3, + 'friendly_name': 'Полотенцосушитель', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 5.0, + 'preset_mode': 'hold', + 'preset_modes': list([ + 'holiday', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.polotentsosushitel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[climate.smart_thermostats-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 90.0, + 'min_temp': 5.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.smart_thermostats', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.sb3zdertrw50bgogkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.smart_thermostats-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'smart thermostats', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 90.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.smart_thermostats', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[climate.sove-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + '1', + '2', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.sove', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.dt4whlrosmnldadvtk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.sove-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.0, + 'fan_mode': 2, + 'fan_modes': list([ + '1', + '2', + ]), + 'friendly_name': 'Sove', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 16.0, + }), + 'context': , + 'entity_id': 'climate.sove', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[climate.term_prizemi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 70.0, + 'min_temp': 0.5, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.term_prizemi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.jm2fsqtzuhqtbo5ykw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.term_prizemi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.0, + 'friendly_name': 'Term - Prizemi', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 70.0, + 'min_temp': 0.5, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.term_prizemi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_platform_setup_and_discovery[climate.wifi_smart_gas_boiler_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.wifi_smart_gas_boiler_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.j6mn1t4ut5end6ifkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.wifi_smart_gas_boiler_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.9, + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat ', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.wifi_smart_gas_boiler_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index 90d83d69814..ba5b4f4bb8d 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -11,7 +11,7 @@ 't': 'mocked_t', 'uid': 'mocked_uid', }), - 'user_code': '12345', + 'user_code': 'test_user_code', }), 'disabled_by': None, 'discovery_keys': dict({ @@ -26,7 +26,7 @@ 'source': 'user', 'subentries': list([ ]), - 'title': '12345', + 'title': 'Test Tuya entry', 'unique_id': '12345', 'version': 1, }) diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr new file mode 100644 index 00000000000..0ba09112408 --- /dev/null +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -0,0 +1,358 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[cover.bedroom_blinds_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.bedroom_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.thdfxdqqlccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.bedroom_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'curtain', + 'friendly_name': 'bedroom blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.bedroom_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[cover.blinds_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.nr26obpclccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 36, + 'device_class': 'curtain', + 'friendly_name': 'blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_blinds_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blind', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'blind', + 'unique_id': 'tuya.ftvxinxevpy21tbelcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'Kitchen Blinds Blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.dke76hazlccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 48, + 'device_class': 'curtain', + 'friendly_name': 'Kitchen Blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.lounge_dark_blind_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.lounge_dark_blind_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.lounge_dark_blind_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Lounge Dark Blind Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.lounge_dark_blind_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.persiana_do_quarto_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.persiana_do_quarto_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.ikbbdbnqsd70pc1glccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.persiana_do_quarto_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Persiana do Quarto Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.persiana_do_quarto_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.tapparelle_studio_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.tapparelle_studio_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.2w46jyhngklccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.tapparelle_studio_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'curtain', + 'friendly_name': 'Tapparelle studio Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.tapparelle_studio_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..33248655d31 --- /dev/null +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -0,0 +1,183 @@ +# serializer version: 1 +# name: test_device_diagnostics[rqbj_4iqe2hsfyd86kwwc] + dict({ + 'active_time': '2025-06-24T20:33:10+00:00', + 'category': 'rqbj', + 'create_time': '2025-06-24T20:33:10+00:00', + 'disabled_by': None, + 'disabled_polling': False, + 'endpoint': 'https://apigw.tuyaeu.com', + 'function': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': 'gas', + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': 'measurement', + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.gas_sensor_gas', + 'state': '0.0', + }), + 'unit_of_measurement': 'ppm', + }), + ]), + 'name': 'Gas sensor', + 'name_by_user': None, + }), + 'id': 'cwwk68dyfsh2eqi4jbqr', + 'mqtt_connected': True, + 'name': 'Gas sensor', + 'online': True, + 'product_id': '4iqe2hsfyd86kwwc', + 'product_name': 'Gas sensor', + 'set_up': True, + 'status': dict({ + 'alarm_time': 300, + 'checking_result': 'check_success', + 'gas_sensor_status': 'normal', + 'gas_sensor_value': 0, + 'muffling': True, + 'self_checking': False, + }), + 'status_range': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'sub': False, + 'support_local': True, + 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', + 'time_zone': '-04:00', + 'update_time': '2025-06-24T20:33:10+00:00', + }) +# --- +# name: test_entry_diagnostics[rqbj_4iqe2hsfyd86kwwc] + dict({ + 'devices': list([ + dict({ + 'active_time': '2025-06-24T20:33:10+00:00', + 'category': 'rqbj', + 'create_time': '2025-06-24T20:33:10+00:00', + 'function': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': 'gas', + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': 'measurement', + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.gas_sensor_gas', + 'state': '0.0', + }), + 'unit_of_measurement': 'ppm', + }), + ]), + 'name': 'Gas sensor', + 'name_by_user': None, + }), + 'id': 'cwwk68dyfsh2eqi4jbqr', + 'name': 'Gas sensor', + 'online': True, + 'product_id': '4iqe2hsfyd86kwwc', + 'product_name': 'Gas sensor', + 'set_up': True, + 'status': dict({ + 'alarm_time': 300, + 'checking_result': 'check_success', + 'gas_sensor_status': 'normal', + 'gas_sensor_value': 0, + 'muffling': True, + 'self_checking': False, + }), + 'status_range': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'sub': False, + 'support_local': True, + 'time_zone': '-04:00', + 'update_time': '2025-06-24T20:33:10+00:00', + }), + ]), + 'disabled_by': None, + 'disabled_polling': False, + 'endpoint': 'https://apigw.tuyaeu.com', + 'mqtt_connected': True, + 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr new file mode 100644 index 00000000000..ce7c1cf67de --- /dev/null +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -0,0 +1,180 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bathroom_smart_switch_button_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwswitch_mode1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'press', + ]), + 'friendly_name': 'Bathroom Smart Switch Button 1', + }), + 'context': , + 'entity_id': 'event.bathroom_smart_switch_button_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bathroom_smart_switch_button_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwswitch_mode2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'press', + ]), + 'friendly_name': 'Bathroom Smart Switch Button 2', + }), + 'context': , + 'entity_id': 'event.bathroom_smart_switch_button_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[event.bouton_tempo_exterieur_button_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'double_click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bouton_tempo_exterieur_button_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.g5uso5ajgkxwswitch_mode1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[event.bouton_tempo_exterieur_button_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'double_click', + 'press', + ]), + 'friendly_name': 'Bouton tempo extérieur Button 1', + }), + 'context': , + 'entity_id': 'event.bouton_tempo_exterieur_button_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr new file mode 100644 index 00000000000..f2b615ec269 --- /dev/null +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -0,0 +1,516 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[fan.bree-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.bree', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.bree-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree', + 'preset_mode': 'normal', + 'preset_modes': list([ + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.bree', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[fan.ceiling_fan_light_v2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan_light_v2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.6wxksqu35c61sce9dsf', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.ceiling_fan_light_v2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'forward', + 'friendly_name': 'ceiling fan/Light v2', + 'percentage': 20, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ceiling_fan_light_v2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fan.ceiling_fan_with_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan_with_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.ijzjlqwmv1blwe0gsf', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'reverse', + 'friendly_name': 'Ceiling Fan With Light', + 'percentage': None, + 'percentage_step': 16.666666666666668, + 'preset_mode': 'normal', + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fan.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.ifzgvpgoodrfw2aksc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer', + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.2myxayqtud9aqbizsc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fan.hl400-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hl400', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.hl400-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hl400', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fan.ion1000pro-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ion1000pro', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.owozxdzgbibizu4sjk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.ion1000pro-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ion1000pro', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fan.kalado_air_purifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'manual', + 'auto', + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.kalado_air_purifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.yo2karkjuhzztxsfjk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.kalado_air_purifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier', + 'preset_mode': 'auto', + 'preset_modes': list([ + 'manual', + 'auto', + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.kalado_air_purifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[fan.tower_fan_ca_407g_smart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.tower_fan_ca_407g_smart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.lflvu8cazha8af9jsk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.tower_fan_ca_407g_smart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart', + 'oscillating': True, + 'percentage': 37, + 'percentage_step': 1.0, + 'preset_mode': 'ordinary', + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.tower_fan_ca_407g_smart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000..46535810d7d --- /dev/null +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[humidifier.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 80, + 'min_humidity': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ifzgvpgoodrfw2akscswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifer', + 'max_humidity': 80, + 'min_humidity': 25, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 70, + 'min_humidity': 35, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.2myxayqtud9aqbizscswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 47, + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifier', + 'humidity': 50, + 'max_humidity': 70, + 'min_humidity': 35, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr new file mode 100644 index 00000000000..5eb43abd365 --- /dev/null +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -0,0 +1,6263 @@ +# serializer version: 1 +# name: test_device_registry[2k8wyjo7iidkohuczc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '2k8wyjo7iidkohuczc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug-EU', + 'model_id': 'cuhokdii7ojyw8k2', + 'name': 'Buitenverlichting', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2myxayqtud9aqbizsc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '2myxayqtud9aqbizsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Arete® Two 12L Dehumidifier/Air Purifier', + 'model_id': 'zibqa9dutqyaxym2', + 'name': 'Dehumidifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2pxfek1jjrtctiyglam] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '2pxfek1jjrtctiyglam', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Multifunction alarm', + 'model_id': 'gyitctrjj1kefxp2', + 'name': 'Multifunction alarm', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2w46jyhngklc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '2w46jyhngklc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch', + 'model_id': 'nhyj64w2', + 'name': 'Tapparelle studio', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[2x473nefusdo7af6zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '2x473nefusdo7af6zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '5GHz plug', + 'model_id': '6fa7odsufen374x2', + 'name': 'Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[3d4yosotwk27nqxvzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '3d4yosotwk27nqxvzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug+', + 'model_id': 'vxqn72kwtosoy4d3', + 'name': 'Garage Socket', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[3phkffywh5nnlj5vbdnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '3phkffywh5nnlj5vbdnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Meter', + 'model_id': 'v5jlnn5hwyffkhp3', + 'name': 'Production', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[3uqk1csjqplf3uxqscm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '3uqk1csjqplf3uxqscm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Contact Sensor', + 'model_id': 'qxu3flpqjsc1kqu3', + 'name': 'Garage Contact Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[49m7h9lh3t8pq6ftzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '49m7h9lh3t8pq6ftzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'smart meter with CT-2', + 'model_id': 'tf6qp8t3hl9h7m94', + 'name': 'Consommation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[4fO1qIzYbcdMUHqAjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '4fO1qIzYbcdMUHqAjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Bulb', + 'model_id': 'AqHUMdcbYzIq1Of4', + 'name': 'Landing', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[4pa1uobdjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '4pa1uobdjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'atmosphere', + 'model_id': 'dbou1ap4', + 'name': 'Lumy Garage', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[4q5c2am8n1bwb6bszc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '4q5c2am8n1bwb6bszc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WIFI 插座', + 'model_id': 'sb6bwb1n8ma2c5q4', + 'name': 'Socket4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[51tdkcsamisw9ukycp] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '51tdkcsamisw9ukycp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Konyks Priska USB', + 'model_id': 'yku9wsimasckdt15', + 'name': 'Framboisier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[53apxfah2qoxb1cgkw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '53apxfah2qoxb1cgkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ET-44W', + 'model_id': 'gc1bxoq2hafxpa35', + 'name': 'Полотенцосушитель', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[53fnjncm3jywuaznps] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '53fnjncm3jywuaznps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Camera ', + 'model_id': 'nzauwyj3mcnjnf35', + 'name': 'Garage Camera', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[5gfyvvg48bsxbbnjzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '5gfyvvg48bsxbbnjzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Plug Base 6210HA', + 'model_id': 'jnbbxsb84gvvyfg5', + 'name': 'Bathroom Fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[63cninaczt9dwo7v2gw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '63cninaczt9dwo7v2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Gateway (unsupported)', + 'model_id': 'v7owd9tzcaninc36', + 'name': 'Gateway2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[69dth3rxgcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '69dth3rxgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature Humidity Sensor', + 'model_id': 'xr3htd96', + 'name': 'Humy toilettes RDC', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6ffyxwrjsuydxhqrqkynw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '6ffyxwrjsuydxhqrqkynw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart IR (unsupported)', + 'model_id': 'rqhxdyusjrwxyff6', + 'name': 'Smart IR', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6gsqieoh1yzjvxlnjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '6gsqieoh1yzjvxlnjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'nlxvjzy1hoeiqsg6', + 'name': 'hall 💡 ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6h8boeqxorpsmtj] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '6h8boeqxorpsmtj', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'S1-TY-BLE-PRO (unsupported)', + 'model_id': 'xqeob8h6', + 'name': 'S1-TY-BLE-PRO 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6o148laaosbf0g4djd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '6o148laaosbf0g4djd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'A60 GOLD', + 'model_id': 'd4g0fbsoaal841o6', + 'name': 'WC D1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6tbtkuv3tal1aesfjxq] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '6tbtkuv3tal1aesfjxq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'BR 7-in-1 WLAN Wetterstation Anthrazit', + 'model_id': 'fsea1lat3vuktbt6', + 'name': 'BR 7-in-1 WLAN Wetterstation Anthrazit', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[6wxksqu35c61sce9dsf] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '6wxksqu35c61sce9dsf', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ceiling fan/Light v2', + 'model_id': '9ecs16c53uqskxw6', + 'name': 'ceiling fan/Light v2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[73ov8i8iedtylkzrqzkfs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '73ov8i8iedtylkzrqzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Water Timer', + 'model_id': 'rzklytdei8i8vo37', + 'name': 'balkonbewässerung', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7axah58vfydd8cphjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '7axah58vfydd8cphjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'RGB Smart Plug', + 'model_id': 'hpc8ddyfv85haxa7', + 'name': 'Garage', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7jxnjpiltmj2zyaijd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '7jxnjpiltmj2zyaijd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED Strip RGB+W', + 'model_id': 'iayz2jmtlipjnxj7', + 'name': 'LED Porch 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7obpyhy8scm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '7obpyhy8scm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Sensor', + 'model_id': '8yhypbo7', + 'name': 'Boîte aux lettres - arrière', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[7zogt3pcwhxhu8upqdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '7zogt3pcwhxhu8upqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug +', + 'model_id': 'pu8uhxhwcp3tgoz7', + 'name': 'Socket3', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[86kdcut3hiqqddlijd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '86kdcut3hiqqddlijd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'ilddqqih3tucdk68', + 'name': 'Ieskas', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[87yarxyp23ap1vazjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '87yarxyp23ap1vazjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Ceiling Light RGBTW', + 'model_id': 'zav1pa32pyxray78', + 'name': 'Gengske 💡 ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[97k3pwirjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '97k3pwirjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'atmosphere', + 'model_id': 'riwp3k79', + 'name': 'LED KEUKEN 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[9oh1h1uyalfykgg4bdnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '9oh1h1uyalfykgg4bdnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'XOCA-DAC212XC V2-S1', + 'model_id': '4ggkyflayu1h1ho9', + 'name': 'XOCA-DAC212XC V2-S1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[9wlo8cpzprhiclrkgcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '9wlo8cpzprhiclrkgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'IFS-STD002', + 'model_id': 'krlcihrpzpc8olw9', + 'name': 'IFS-STD002', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[AUTwCwqDY9EjlQSocm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'AUTwCwqDY9EjlQSocm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Sensor', + 'model_id': 'oSQljE9YDqwCwTUA', + 'name': 'Kippenluik', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[CyD4ctKVrAFSSXSbjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'CyD4ctKVrAFSSXSbjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Dimmer switch', + 'model_id': 'bSXSSFArVKtc4DyC', + 'name': 'bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[HzsAAAKFLPABVi8nzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'HzsAAAKFLPABVi8nzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Socket', + 'model_id': 'n8iVBAPLFKAAAszH', + 'name': 'Steckdose 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[LJ9zTFQTfMgsG2Ahzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'LJ9zTFQTfMgsG2Ahzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mini Smart Socket', + 'model_id': 'hA2GsgMfTQFTz9JL', + 'name': 'Spot 4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[LS6FfVBVU1vzBRBHzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'LS6FfVBVU1vzBRBHzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Rewireable Plug 6930HA', + 'model_id': 'HBRBzv1UVBVfF6SL', + 'name': 'Rewireable Plug 6930HA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[NVjuXIQ6QH9eZLHCzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'NVjuXIQ6QH9eZLHCzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'CHLZe9HQ6QIXujVN', + 'name': 'schuur', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[O8QpxJwdme33sqn4gk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'O8QpxJwdme33sqn4gk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SWITCH1', + 'model_id': '4nqs33emdwJxpQ8O', + 'name': 'office lights', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ZgXzZULP6dDp4Atvgcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ZgXzZULP6dDp4Atvgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature and humidity sensor', + 'model_id': 'vtA4pDd6PLUZzXgZ', + 'name': 'Humy bain', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[a3qtb7pulkcc6jdjqld] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'a3qtb7pulkcc6jdjqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Din Rail Switch with metering', + 'model_id': 'jdj6ccklup7btq3a', + 'name': 'Eau Chaude', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[a4zeazrz1ata9mbggk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'a4zeazrz1ata9mbggk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Valve', + 'model_id': 'gbm9ata1zrzaez4a', + 'name': 'QT-Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[aa99hccfnzvypr3zjsywc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'aa99hccfnzvypr3zjsywc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'z3rpyvznfcch99aa', + 'name': 'PIXI Smart Drinking Fountain', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[aje5kxgmhhxdihqizc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'aje5kxgmhhxdihqizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'iqhidxhhmgxk5eja', + 'name': 'Powerplug 5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ajkdo1bm2rcmpuufjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ajkdo1bm2rcmpuufjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ST64 Clear', + 'model_id': 'fuupmcr2mb1odkja', + 'name': 'Slaapkamer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[aoyweq8xbx7qfndijd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'aoyweq8xbx7qfndijd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Lamp', + 'model_id': 'idnfq7xbx8qewyoa', + 'name': 'AB1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ase6htln9tdni2sijxq] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ase6htln9tdni2sijxq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T & H Sensor with external probe', + 'model_id': 'is2indt9nlth6esa', + 'name': 'Frysen', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[b6e05dfy4qhpgea1qdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'b6e05dfy4qhpgea1qdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '1-433', + 'model_id': '1aegphq4yfd50e6b', + 'name': 'jardin Fraises', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bFFsO8HimyAJGIj7scm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'bFFsO8HimyAJGIj7scm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Sensor', + 'model_id': '7jIGJAymiH8OsFFb', + 'name': 'Door Garage ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bak2crzmabancwqvjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'bak2crzmabancwqvjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Light Strip-RGBCW ', + 'model_id': 'vqwcnabamzrc2kab', + 'name': 'Strip 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bcyciyhhu1g2gk9rqld] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'bcyciyhhu1g2gk9rqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Breaker ', + 'model_id': 'r9kg2g1uhhyicycb', + 'name': 'P1 Energia Elettrica', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bescacsciyam3aouqdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'bescacsciyam3aouqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain switch (unsupported)', + 'model_id': 'uoa3mayicscacseb', + 'name': 'Living room left', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bgnj6bafrdgb1xmajd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'bgnj6bafrdgb1xmajd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'A60 Clear', + 'model_id': 'amx1bgdrfab6jngb', + 'name': 'Lumy Hall', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[buzituffc13pgb1jjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'buzituffc13pgb1jjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Smart Ceiling Light', + 'model_id': 'j1bgp31cffutizub', + 'name': 'Ceiling Portal', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[bxfkpxjgux2fgwnazc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'bxfkpxjgux2fgwnazc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket ', + 'model_id': 'anwgf2xugjxpkfxb', + 'name': 'Security Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[c1tfgunpf6optybisf] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'c1tfgunpf6optybisf', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tower bladeless fan (unsupported)', + 'model_id': 'ibytpo6fpnugft1c', + 'name': 'Ventilador Cama', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cijerqyssiwrf7deqzkfs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'cijerqyssiwrf7deqzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '接HA水阀', + 'model_id': 'ed7frwissyqrejic', + 'name': '接HA水阀', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cju47ovcbeuapei2zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'cju47ovcbeuapei2zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Aubess Smart\xa0Socket 20A/EM', + 'model_id': '2iepauebcvo74ujc', + 'name': 'Aubess Cooker', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[codvtvgtjs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'codvtvgtjs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Rain sensor', + 'model_id': 'tgvtvdoc', + 'name': 'Tournesol', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[couukaypjdnyt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'couukaypjdnyt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Solar flood light App panel', + 'model_id': 'pyakuuoc', + 'name': 'Solar zijpad', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cq4hzlrnqn4qi0mqzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'cq4hzlrnqn4qi0mqzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'qm0iq4nqnrlzh4qc', + 'name': 'Elivco Kitchen Socket', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[cwwk68dyfsh2eqi4jbqr] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'cwwk68dyfsh2eqi4jbqr', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Gas sensor', + 'model_id': '4iqe2hsfyd86kwwc', + 'name': 'Gas sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dke76hazlc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'dke76hazlc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'AM43拉绳电机-Zigbee', + 'model_id': 'zah67ekd', + 'name': 'Kitchen Blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dn7cjik6kw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'dn7cjik6kw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Thermostat Tervix Pro Line ZigBee color', + 'model_id': '6kijc7nd', + 'name': 'Кабінет', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[dt4whlrosmnldadvtk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'dt4whlrosmnldadvtk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'YFA-05C', + 'model_id': 'vdadlnmsorlhw4td', + 'name': 'Sove', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[e2sbdwuga5jorvejtkdy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'e2sbdwuga5jorvejtkdy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'DOLCECLIMA 10 HP WIFI (unsupported)', + 'model_id': 'jevroj5aguwdbs2e', + 'name': 'DOLCECLIMA 10 HP WIFI', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[eway2kw92ncuecarzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'eway2kw92ncuecarzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Inline Switch 6000HA', + 'model_id': 'raceucn29wk2yawe', + 'name': 'Bathroom Mirror', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fasvixqysw1lxvjprd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'fasvixqysw1lxvjprd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Sunbeam Bedding', + 'model_id': 'pjvxl1wsyqxivsaf', + 'name': 'Sunbeam Bedding', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fbya6s6rhaoyvl8hqgcwy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'fbya6s6rhaoyvl8hqgcwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tank A Level', + 'model_id': 'h8lvyoahr6s6aybf', + 'name': 'Rainwater Tank Level', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fcdadqsiax2gvnt0qld] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'fcdadqsiax2gvnt0qld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '一路带计量磁保持通断器', + 'model_id': '0tnvg2xaisqdadcf', + 'name': '一路带计量磁保持通断器', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fjdyw5ld2f5f5ddsps] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'fjdyw5ld2f5f5ddsps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Security Camera', + 'model_id': 'sdd5f5f2dl5wydjf', + 'name': 'C9', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ftvxinxevpy21tbelc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ftvxinxevpy21tbelc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Blinds', + 'model_id': 'ebt12ypvexnixvtf', + 'name': 'Kitchen Blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[fvywp3b5mu4zay8lgkxw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'fvywp3b5mu4zay8lgkxw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Wireless Switch', + 'model_id': 'l8yaz4um5b3pwyvf', + 'name': 'Bathroom Smart Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g0edqq0wzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'g0edqq0wzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'w0qqde0g', + 'name': 'Lave linge', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g1efxsqnp33cg8r3lc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'g1efxsqnp33cg8r3lc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Blinds Controller', + 'model_id': '3r8gc33pnqsxfe1g', + 'name': 'Lounge Dark Blind', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g1fmm26qhhrimmbitk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'g1fmm26qhhrimmbitk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T platform model-USB ', + 'model_id': 'ibmmirhhq62mmf1g', + 'name': 'Master Bedroom AC', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g5uso5ajgkxw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'g5uso5ajgkxw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ZC-YED-一键无线开关', + 'model_id': 'ja5osu5g', + 'name': 'Bouton tempo extérieur', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[g7af6lrt4miugbstcp] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'g7af6lrt4miugbstcp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Power Strip', + 'model_id': 'tsbguim4trl6fa7g', + 'name': 'Keller', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ggwxkj8bwn5y63flgcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ggwxkj8bwn5y63flgcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T & H Sensor', + 'model_id': 'lf36y5nwb8jkxwgg', + 'name': 'Greenhouse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gi69tunb0esxcnefzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gi69tunb0esxcnefzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'smart plug', + 'model_id': 'fencxse0bnut96ig', + 'name': 'Spa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gjnpc0eojd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gjnpc0eojd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Lighting', + 'model_id': 'oe0cpnjg', + 'name': 'Front right Lighting trap', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gluaktf5gk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gluaktf5gk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '1Gang Zigbee Switch', + 'model_id': '5ftkaulg', + 'name': 'bathroom light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gnqwzcph94wj2sl5nq] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gnqwzcph94wj2sl5nq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mr. Pure', + 'model_id': '5ls2jw49hpczwqng', + 'name': 'Mr. Pure', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gt1q9tldv1opojrtcp] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gt1q9tldv1opojrtcp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Garden Spike(FR)', + 'model_id': 'trjopo1vdlt9q1tg', + 'name': 'Terras', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[gvxxy4jitzltz5xhscm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gvxxy4jitzltz5xhscm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Door Detector', + 'model_id': 'hx5ztlztij4yxxvg', + 'name': 'Steel cage door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hfqeljop3aihnm73zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'hfqeljop3aihnm73zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'SP111', + 'model_id': '37mnhia3pojleqfh', + 'name': 'Sapphire ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hkm4px9ohzozxma3rip] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'hkm4px9ohzozxma3rip', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Motion Sensor', + 'model_id': '3amxzozho9xp4mkh', + 'name': 'rat trap hedge', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hxbonj4yzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'hxbonj4yzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '【通用接入】1路插座', + 'model_id': 'y4jnobxh', + 'name': 'AuVeLiCo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hyda5jsihokacvaqjzm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'hyda5jsihokacvaqjzm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Sous Vide', + 'model_id': 'qavcakohisj5adyh', + 'name': 'Sous Vide', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[hz4pau766eavmxhqsc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'hz4pau766eavmxhqsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': ' (unsupported)', + 'model_id': 'qhxmvae667uap4zh', + 'name': 'DryFix', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[iaagy4qigcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'iaagy4qigcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Temperature Sensor', + 'model_id': 'iq4ygaai', + 'name': 'Bassin', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ifzgvpgoodrfw2aksc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ifzgvpgoodrfw2aksc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Emma Dehumidifier - eeese air care', + 'model_id': 'ka2wfrdoogpvgzfi', + 'name': 'Dehumidifer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ijne16zv8vpqmubnjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ijne16zv8vpqmubnjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC smart GU10', + 'model_id': 'nbumqpv8vz61enji', + 'name': 'b2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ijzjlqwmv1blwe0gsf] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ijzjlqwmv1blwe0gsf', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Ceiling Fan With Light', + 'model_id': 'g0ewlb1vmwqljzji', + 'name': 'Ceiling Fan With Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ikbbdbnqsd70pc1glc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ikbbdbnqsd70pc1glc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart roller blinds', + 'model_id': 'g1cp07dsqnbdbbki', + 'name': 'Persiana do Quarto', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[iks13mcaiyie3rryjb2oc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'iks13mcaiyie3rryjb2oc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'AIR_DETECTOR ', + 'model_id': 'yrr3eiyiacm31ski', + 'name': 'AQI', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ilms5pwjzzsxuxmvsc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ilms5pwjzzsxuxmvsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'the Smart Dry Plus™ Connect Dehumidifier (unsupported)', + 'model_id': 'vmxuxszzjwp5smli', + 'name': 'Dehumidifier ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[iomszlsve0yyzkfwqswwc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'iomszlsve0yyzkfwqswwc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Cleverio PF100', + 'model_id': 'wfkzyy0evslzsmoi', + 'name': 'Cleverio PF100', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[j6mn1t4ut5end6ifkw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'j6mn1t4ut5end6ifkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Smart Gas Boiler Thermostat ', + 'model_id': 'fi6dne5tu4t1nm6j', + 'name': 'WiFi Smart Gas Boiler Thermostat ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jfpdpavoqgoqsn3cjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'jfpdpavoqgoqsn3cjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'RGBstriplight', + 'model_id': 'c3nsqogqovapdpfj', + 'name': 'Arbeitszimmer led', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jfydgffzmhjed9fgjbwy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'jfydgffzmhjed9fgjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Smoke Alarm', + 'model_id': 'gf9dejhmzffgdyfj', + 'name': ' Smoke detector upstairs ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jlduh7vigcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'jlduh7vigcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Bluetooth Temperature Humidity Sensor', + 'model_id': 'iv7hudlj', + 'name': 'Basement temperature', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[jm2fsqtzuhqtbo5ykw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'jm2fsqtzuhqtbo5ykw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart', + 'model_id': 'y5obtqhuztqsf2mj', + 'name': 'Term - Prizemi', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kcdngswaxs8hm52bnocfw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kcdngswaxs8hm52bnocfw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'ZigBee Gateway (unsupported)', + 'model_id': 'b25mh8sxawsgndck', + 'name': 'ZigBee Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kffnst1epj6vr8xnzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kffnst1epj6vr8xnzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'nx8rv6jpe1tsnffk', + 'name': 'Spot 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kjr0pqg7eunn4vlujbgs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kjr0pqg7eunn4vlujbgs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Siren Sensor', + 'model_id': 'ulv4nnue7gqp0rjk', + 'name': 'Siren veranda ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kkande5hk6sfdkoxjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kkande5hk6sfdkoxjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC-G125-Gold ', + 'model_id': 'xokdfs6kh5ednakk', + 'name': 'ERKER 1-Gold ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[klgxmpwvdhw7tzs8jd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'klgxmpwvdhw7tzs8jd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Light Bulb', + 'model_id': '8szt7whdvwpmxglk', + 'name': 'Porch light E', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ksy8guiy64acbbpnqkynw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ksy8guiy64acbbpnqkynw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart IR (unsupported)', + 'model_id': 'npbbca46yiug8ysk', + 'name': 'Bedroom IR', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kta28zbwj6u0xa6lbsgy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kta28zbwj6u0xa6lbsgy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '水泵 (unsupported)', + 'model_id': 'l6ax0u6jwbz82atk', + 'name': 'Pond', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kvnsoqyfltmf0bknzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kvnsoqyfltmf0bknzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Konyks Pluviose Easy EU', + 'model_id': 'nkb0fmtlfyqosnvk', + 'name': 'Bassin', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kx8dncf1qzkfs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kx8dncf1qzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Valve Controller', + 'model_id': '1fcnd8xk', + 'name': 'Valve Controller 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kxwleaa2sph] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kxwleaa2sph', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Human presence sensor', + 'model_id': '2aaelwxk', + 'name': 'Human presence Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[kxxrbv93k2vvkconqdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kxxrbv93k2vvkconqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '6 Switch Smart RetroFit Module', + 'model_id': 'nockvv2k39vbrxxk', + 'name': 'Seating side 6-ch Smart Switch ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[l8uxezzkc7c5a0jhzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'l8uxezzkc7c5a0jhzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'hj0a5c7ckzzexu8l', + 'name': 'droger', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[lflvu8cazha8af9jsk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'lflvu8cazha8af9jsk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tower Fan CA-407G Smart', + 'model_id': 'j9fa8ahzac8uvlfl', + 'name': 'Tower Fan CA-407G Smart', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mgcpxpmovasazerdps] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'mgcpxpmovasazerdps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Indoor camera ', + 'model_id': 'drezasavompxpcgm', + 'name': 'CAM GARAGE', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mpowx36sgqexmtes2gw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'mpowx36sgqexmtes2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'setmxeqgs63xwopm', + 'name': 'Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mvsdcwtskkezlnw5tk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'mvsdcwtskkezlnw5tk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '移动空调 YPK--(双模+蓝牙)低功耗', + 'model_id': '5wnlzekkstwcdsvm', + 'name': 'Air Conditioner', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[mwsaod7fa3gjyh6ids] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'mwsaod7fa3gjyh6ids', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'E20', + 'model_id': 'i6hyjg3af7doaswm', + 'name': 'Hoover', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ncl7oi5d6hqmf1g0zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ncl7oi5d6hqmf1g0zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Mini Smart Plug', + 'model_id': '0g1fmqh6d5io7lcn', + 'name': 'Apollo light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ngcubvaqoraolsmtjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ngcubvaqoraolsmtjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'G95-Filament', + 'model_id': 'tmsloaroqavbucgn', + 'name': 'Pokerlamp 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nnqlg0rxryraf8ezbdnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'nnqlg0rxryraf8ezbdnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'PJ2101A 1P WiFi Smart Meter ', + 'model_id': 'ze8faryrxr0glqnn', + 'name': 'Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nr26obpclc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'nr26obpclc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'curtain robot', + 'model_id': 'cpbo62rn', + 'name': 'blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nt3mpibadxfqkegldyg] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'nt3mpibadxfqkegldyg', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Colorful PIR Night Light', + 'model_id': 'lgekqfxdabipm3tn', + 'name': 'Colorful PIR Night Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[nxdcy0uidplnhkazjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'nxdcy0uidplnhkazjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'zakhnlpdiu0ycdxn', + 'name': 'Stoel', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[o71einxvuuktuljcjbwy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'o71einxvuuktuljcjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smoke Alarm', + 'model_id': 'cjlutkuuvxnie17o', + 'name': 'Rauchmelder Alexsandro ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[obb7p55c0us6rdxkqld] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'obb7p55c0us6rdxkqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Metering_3PN_WiFi', + 'model_id': 'kxdr6su0c55p7bbo', + 'name': 'Metering_3PN_WiFi_stable', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ohefbbk9gcdl] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ohefbbk9gcdl', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Luminance sensor', + 'model_id': '9kbbfeho', + 'name': 'Luminosité', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ol8xwtcj42eg18bdbrnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ol8xwtcj42eg18bdbrnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Heat Pump', + 'model_id': 'db81ge24jctwx8lo', + 'name': 'Hot Water Heat Pump', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[oqyhsaqwsph] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'oqyhsaqwsph', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Soil moisture sensor', + 'model_id': 'wqashyqo', + 'name': 'Soil moisture sensor #1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[orotles4ucq8rxwn2gw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'orotles4ucq8rxwn2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'X5', + 'model_id': 'nwxr8qcu4seltoro', + 'name': 'X5 Zigbee Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[owozxdzgbibizu4sjk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'owozxdzgbibizu4sjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 's4uzibibgzdxzowo', + 'name': 'ION1000PRO', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[p2gnclbiqxrbboagdd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'p2gnclbiqxrbboagdd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'TV Sync Backlights (unsupported)', + 'model_id': 'gaobbrxqiblcng2p', + 'name': 'TV Sync Backlights', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[p8xoxccrjbwy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'p8xoxccrjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smoke Alarm', + 'model_id': 'rccxox8p', + 'name': 'Smoke Alarm', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pdasfna8fswh4a0tzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'pdasfna8fswh4a0tzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug ', + 'model_id': 't0a4hwsf8anfsadp', + 'name': 'wallwasher front', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pfhwb1v3i7cifa2tcp] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'pfhwb1v3i7cifa2tcp', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Garden Spike(EU)', + 'model_id': 't2afic7i3v1bwhfp', + 'name': 'Bubbelbad', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[ppgdpsq1xaxlyzryjk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'ppgdpsq1xaxlyzryjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '40" Bladeless Tower Fan', + 'model_id': 'yrzylxax1qspdgpp', + 'name': 'Bree', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pykascx9yfqrxtbgzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'pykascx9yfqrxtbgzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug+', + 'model_id': 'gbtxrqfy9xcsakyp', + 'name': '3DPrinter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[pz2xuth8hczv6zrwzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'pz2xuth8hczv6zrwzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Plug', + 'model_id': 'wrz6vzch8htux2zp', + 'name': 'Elivco TV', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[q304vac40br8nlkajsywc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'q304vac40br8nlkajsywc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Pet Water Fountain', + 'model_id': 'akln8rb04cav403q', + 'name': 'Water Fountain', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[q62sg0p3s52thp6zzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'q62sg0p3s52thp6zzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '6294HA', + 'model_id': 'z6pht25s3p0gs26q', + 'name': '6294HA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[q8dncqpgin4yympisc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'q8dncqpgin4yympisc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '30L Dehumidifier with Max Extraction', + 'model_id': 'ipmyy4nigpqcnd8q', + 'name': 'Pro Breeze 30L Compressor Dehumidifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[qhgghufzqtwloqoqjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'qhgghufzqtwloqoqjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Bulb RGBCW', + 'model_id': 'qoqolwtqzfuhgghq', + 'name': 'Smart Bulb RGBCW', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[qi94v9dmdx4fkpncqld] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'qi94v9dmdx4fkpncqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Breaker', + 'model_id': 'cnpkf4xdmd9v49iq', + 'name': '断路器HA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[qifhbafbqubbp3b6qbnnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'qifhbafbqubbp3b6qbnnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Wi-Fi solar grid micro inverter (GT)', + 'model_id': '6b3pbbuqbfabhfiq', + 'name': 'Wi-Fi solar grid micro inverter (GT)', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[queafegmhhmtivdxjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'queafegmhhmtivdxjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'GU10 Smart Bulb', + 'model_id': 'xdvitmhhmgefaeuq', + 'name': 'druckerhell', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[qyy1auihjyoogvb7zdccq] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'qyy1auihjyoogvb7zdccq', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'AC charging control box', + 'model_id': '7bvgooyjhiua1yyq', + 'name': 'AC charging control box', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[r4yrlr705ei31ikmjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'r4yrlr705ei31ikmjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Light Bulb', + 'model_id': 'mki13ie507rlry4r', + 'name': 'Garage light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rdq0bn4dzuwx2qfujd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'rdq0bn4dzuwx2qfujd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Ceiling Lamp', + 'model_id': 'ufq2xwuzd4nb0qdr', + 'name': 'Sjiethoes', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rl39uwgaqwjwc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'rl39uwgaqwjwc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Odor Eliminator-Pro', + 'model_id': 'agwu93lr', + 'name': 'Smart Odor Eliminator-Pro', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rojky4l6yyjreeilnocfw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'rojky4l6yyjreeilnocfw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Zigbee Smart Gateway (unsupported)', + 'model_id': 'lieerjyy6l4ykjor', + 'name': 'Zigbee Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rsjdwgnbqky] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'rsjdwgnbqky', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Remote controller (unsupported)', + 'model_id': 'bngwdjsr', + 'name': 'Télécommande lumières ZigBee', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[rwp6kdezm97s2nktzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'rwp6kdezm97s2nktzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket ', + 'model_id': 'tkn2s79mzedk6pwr', + 'name': 'Weihnachtsmann ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[s3zzjdcfrip] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 's3zzjdcfrip', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Motion sensor', + 'model_id': 'fcdjzz3s', + 'name': 'Motion sensor lidl zigbee', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sb3zdertrw50bgogkw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'sb3zdertrw50bgogkw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'smart thermostats', + 'model_id': 'gogb05wrtredz3bs', + 'name': 'smart thermostats', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sdq2flqkq0lblcah2gw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'sdq2flqkq0lblcah2gw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Multi-mode Gateway', + 'model_id': 'haclbl0qkqlf2qds', + 'name': 'Home Gateway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sifg4pfqsylsayg0jd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'sifg4pfqsylsayg0jd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': '0gyaslysqfp4gfis', + 'name': 'Study 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sj55nxhjftilowkejd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'sj55nxhjftilowkejd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Smart Connect GU10 RGB+CCT', + 'model_id': 'ekwolitfjhxn55js', + 'name': 'ab6', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[snbu4b3vekhywztwqgcwy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'snbu4b3vekhywztwqgcwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Tank A Level', + 'model_id': 'wtzwyhkev3b4ubns', + 'name': 'House Water Level', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[svjjuwykgijjedurps] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'svjjuwykgijjedurps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC PTZ Camera', + 'model_id': 'rudejjigkywujjvs', + 'name': 'Bürocam', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[sw1ejdomlmfubapizc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'sw1ejdomlmfubapizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'ipabufmlmodje1ws', + 'name': 'Värmelampa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[swhtzki3qrz5ydchjboc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'swhtzki3qrz5ydchjboc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WIFI smart CO alarm', + 'model_id': 'hcdy5zrq3ikzthws', + 'name': 'Smogo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[t5zosev6h6wmwyrajbwy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 't5zosev6h6wmwyrajbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smoke Alarm ', + 'model_id': 'arywmw6h6vesoz5t', + 'name': 'Rauchmelder Drucker', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[t7bvnnvplkwhdqm9qtn] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 't7bvnnvplkwhdqm9qtn', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'TION Breezer Bio X (unsupported)', + 'model_id': '9mqdhwklpvnnvb7t', + 'name': 'Бризер Зал', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[tcdk0skzcpisexj2zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'tcdk0skzcpisexj2zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Dual channel metering', + 'model_id': '2jxesipczks0kdct', + 'name': 'HVAC Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[thdfxdqqlc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'thdfxdqqlc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Blinds Drive-BLE', + 'model_id': 'qqdxfdht', + 'name': 'bedroom blinds', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[trffx1ktlyu3tnmljd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'trffx1ktlyu3tnmljd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'lmnt3uyltk1xffrt', + 'name': 'DirectietKamer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[tskafaotnfigad6oqzkfs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'tskafaotnfigad6oqzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Valve Controller', + 'model_id': 'o6dagifntoafakst', + 'name': 'Sprinkler Cesare', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[tvgoe1s3fabebcskjbwy] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'tvgoe1s3fabebcskjbwy', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WIFI Smoke alarm', + 'model_id': 'kscbebaf3s1eogvt', + 'name': 'WIFI Smoke alarm', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[uBLyTOvlhoRWXKjrps] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'uBLyTOvlhoRWXKjrps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Indoor cam Pan/Tilt ', + 'model_id': 'rjKXWRohlvOTyLBu', + 'name': 'CAM PORCH', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[uew54dymycjwz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'uew54dymycjwz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Soil sensor', + 'model_id': 'myd45weu', + 'name': 'Patates', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[urm7i0rtdlabqiqygcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'urm7i0rtdlabqiqygcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'WiFi Temperature & Humidity Sensor', + 'model_id': 'yqiqbaldtr0i7mru', + 'name': 'WiFi Temperature & Humidity Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[uvh6oeqrfliovfiwzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'uvh6oeqrfliovfiwzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': 'wifvoilfrqeo6hvu', + 'name': 'Licht drucker', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vayhq2aj3p3z6y2ggcdsw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'vayhq2aj3p3z6y2ggcdsw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '温湿度传感器wifi', + 'model_id': 'g2y6z3p3ja2qhyav', + 'name': 'NP DownStairs North', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vcrfgwvbuybgnj3zqld] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'vcrfgwvbuybgnj3zqld', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Breaker', + 'model_id': 'z3jngbyubvwgfrcv', + 'name': 'Edesanya Energy', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vnj3sa6mqahro6phjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'vnj3sa6mqahro6phjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED Strip Lights', + 'model_id': 'hp6orhaqm6as3jnv', + 'name': 'Master bedroom TV lights', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vrhdtr5fawoiyth9qdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'vrhdtr5fawoiyth9qdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '1-433', + 'model_id': '9htyiowaf5rtdhrv', + 'name': 'Framboisiers', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vx2owjsg86g2ys93zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'vx2owjsg86g2ys93zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Ineox SP2', + 'model_id': '39sy2g68gsjwo2xv', + 'name': 'Ineox SP2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[vzu7lkknqjz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'vzu7lkknqjz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Zigbee Repeater (unsupported)', + 'model_id': 'nkkl7uzv', + 'name': 'Zigby répéteur ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[w8oht6v8aauqa0y8jd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'w8oht6v8aauqa0y8jd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'A60 Clear', + 'model_id': '8y0aquaa8v6tho8w', + 'name': 'dressoir spot', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[wc6mumew8inrivi9zc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'wc6mumew8inrivi9zc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Socket', + 'model_id': '9ivirni8wemum6cw', + 'name': 'Garáž čerpadlo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[weozorgv28n2scribswh] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'weozorgv28n2scribswh', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'InverFlow (unsupported)', + 'model_id': 'ircs2n82vgrozoew', + 'name': 'InverFlow', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[x4nogasbi8ggpb3lcd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'x4nogasbi8ggpb3lcd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Party String Light RGBIC+CCT ', + 'model_id': 'l3bpgg8ibsagon4x', + 'name': 'LSC Party String Light RGBIC+CCT ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[x7quooqakw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'x7quooqakw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'T7-Air conditioner thermostat(ZIGBEE)', + 'model_id': 'aqoouq7x', + 'name': 'Clima cucina', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[xenxir4a0tn0p1qcqdt] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'xenxir4a0tn0p1qcqdt', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '4-433', + 'model_id': 'cq1p0nt0a4rixnex', + 'name': '4-433', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[yky6kunazmaitupzjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'yky6kunazmaitupzjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LSC Floodlight', + 'model_id': 'zputiamzanuk6yky', + 'name': 'Floodlight', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[yo2karkjuhzztxsfjk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'yo2karkjuhzztxsfjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'fsxtzzhujkrak2oy', + 'name': 'Kalado Air Purifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[yuanswy6scm] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'yuanswy6scm', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Contact Sensor', + 'model_id': '6ywsnauy', + 'name': 'Fenêtre cuisine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[z7cu5t8bl9tt9fabjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'z7cu5t8bl9tt9fabjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'LED SMART', + 'model_id': 'baf9tt9lb8t5uc7z', + 'name': 'Pokerlamp 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[z8woiryqydmzonjdjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'z8woiryqydmzonjdjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Candle RGB-CCT', + 'model_id': 'djnozmdyqyriow8z', + 'name': 'Fakkel 8', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zaszonjgzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zaszonjgzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart plug', + 'model_id': 'gjnozsaz', + 'name': 'Raspy4 - Home Assistant', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zf8vgiwoa07jwegtjd] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zf8vgiwoa07jwegtjd', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'RGBC Smart Bulb', + 'model_id': 'tgewj70aowigv8fz', + 'name': 'Stairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zfHZQ7tZUBxAWjACjk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zfHZQ7tZUBxAWjACjk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'air purifier', + 'model_id': 'CAjWAxBUZt7QZHfz', + 'name': 'HL400', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zgiyrxflahjowpcckw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zgiyrxflahjowpcckw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Intelligent temperature controller', + 'model_id': 'ccpwojhalfxryigz', + 'name': 'Boiler Temperature Controller', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zjh9xhtm3gibs9kizc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zjh9xhtm3gibs9kizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Aubess Smart\xa0Socket EM', + 'model_id': 'ik9sbig3mthx9hjz', + 'name': 'Aubess Washing Machine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zoytcemodrn39zqwrip] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zoytcemodrn39zqwrip', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart PIR sensor', + 'model_id': 'wqz93nrdomectyoz', + 'name': 'PIR outside stairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zrrraytdoanz33rlds] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zrrraytdoanz33rlds', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'V20', + 'model_id': 'lr33znaodtyarrrz', + 'name': 'V20', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zspxfhsvgn2hgtndzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zspxfhsvgn2hgtndzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Plug', + 'model_id': 'dntgh2ngvshfxpsz', + 'name': 'fakkel veranda ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry[zyutbek7wdm1b4cgzckw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zyutbek7wdm1b4cgzckw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '4-TH', + 'model_id': 'gc4b1mdw7kebtuyz', + 'name': 'pid_relay_2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr new file mode 100644 index 00000000000..c18de2a2285 --- /dev/null +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -0,0 +1,3126 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[light.ab1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.ab1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.aoyweq8xbx7qfndijdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ab1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'AB1', + 'hs_color': tuple( + 6.0, + 97.8, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 31, + 6, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.693, + 0.304, + ), + }), + 'context': , + 'entity_id': 'light.ab1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.ab6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.ab6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.sj55nxhjftilowkejdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ab6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ab6', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.ab6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.arbeitszimmer_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.arbeitszimmer_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.jfpdpavoqgoqsn3cjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.arbeitszimmer_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'friendly_name': 'Arbeitszimmer led', + 'hs_color': tuple( + 0.0, + 100.0, + ), + 'rgb_color': tuple( + 255, + 0, + 0, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.701, + 0.299, + ), + }), + 'context': , + 'entity_id': 'light.arbeitszimmer_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.b2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.b2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ijne16zv8vpqmubnjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.b2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'b2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.b2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bedroom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.CyD4ctKVrAFSSXSbjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'bedroom', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.cam_garage_indicator_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.cam_garage_indicator_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_indicator', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.cam_garage_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'CAM GARAGE Indicator light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.cam_garage_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.cam_porch_indicator_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.cam_porch_indicator_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_indicator', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.cam_porch_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'CAM PORCH Indicator light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.cam_porch_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.ceiling_fan_light_v2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.ceiling_fan_light_v2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.6wxksqu35c61sce9dsfswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ceiling_fan_light_v2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'ceiling fan/Light v2', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.ceiling_fan_light_v2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.ceiling_fan_with_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.ceiling_fan_with_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ijzjlqwmv1blwe0gsflight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'Ceiling Fan With Light', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.ceiling_portal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.ceiling_portal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.buzituffc13pgb1jjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ceiling_portal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Ceiling Portal', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.ceiling_portal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.colorful_pir_night_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.colorful_pir_night_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.nt3mpibadxfqkegldygswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.colorful_pir_night_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Colorful PIR Night Light', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.colorful_pir_night_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.directietkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.directietkamer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.trffx1ktlyu3tnmljdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.directietkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'DirectietKamer', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.directietkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.dressoir_spot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dressoir_spot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.w8oht6v8aauqa0y8jdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.dressoir_spot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'dressoir spot', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.dressoir_spot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.druckerhell-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.druckerhell', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.queafegmhhmtivdxjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.druckerhell-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'druckerhell', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.druckerhell', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.erker_1_gold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.erker_1_gold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.kkande5hk6sfdkoxjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.erker_1_gold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'ERKER 1-Gold ', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.erker_1_gold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.fakkel_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fakkel_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.z8woiryqydmzonjdjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.fakkel_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 70, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'Fakkel 8', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.fakkel_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.floodlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.floodlight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.yky6kunazmaitupzjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.floodlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Floodlight', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.floodlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.front_right_lighting_trap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.front_right_lighting_trap', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.gjnpc0eojdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.front_right_lighting_trap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Front right Lighting trap', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.front_right_lighting_trap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.garage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Garage', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.garage_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.r4yrlr705ei31ikmjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 138, + 'color_mode': , + 'friendly_name': 'Garage light', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.garage_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.garage_light_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_light', + 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Garage Light 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.garage_light_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.gengske-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.gengske', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.87yarxyp23ap1vazjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.gengske-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Gengske 💡 ', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.gengske', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hall', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.6gsqieoh1yzjvxlnjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'hall 💡 ', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.hall', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.ieskas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.ieskas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.86kdcut3hiqqddlijdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ieskas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 285, + 'color_temp_kelvin': 3508, + 'friendly_name': 'Ieskas', + 'hs_color': tuple( + 27.165, + 44.6, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 193, + 141, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.453, + 0.374, + ), + }), + 'context': , + 'entity_id': 'light.ieskas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.landing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.landing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.4fO1qIzYbcdMUHqAjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.landing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Landing', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.landing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.led_keuken_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.led_keuken_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.97k3pwirjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.led_keuken_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'LED KEUKEN 2', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.led_keuken_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.led_porch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.led_porch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.7jxnjpiltmj2zyaijdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.led_porch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LED Porch 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.led_porch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.lsc_party_string_light_rgbic_cct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.lsc_party_string_light_rgbic_cct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.x4nogasbi8ggpb3lcdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.lsc_party_string_light_rgbic_cct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LSC Party String Light RGBIC+CCT ', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.lsc_party_string_light_rgbic_cct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.lumy_garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.lumy_garage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.4pa1uobdjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.lumy_garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Lumy Garage', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.lumy_garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.lumy_hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.lumy_hall', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bgnj6bafrdgb1xmajdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.lumy_hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Lumy Hall', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.lumy_hall', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.master_bedroom_tv_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.master_bedroom_tv_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.vnj3sa6mqahro6phjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.master_bedroom_tv_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 51, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Master bedroom TV lights', + 'hs_color': tuple( + 26.072, + 100.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 111, + 0, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.632, + 0.358, + ), + }), + 'context': , + 'entity_id': 'light.master_bedroom_tv_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.pokerlamp_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ngcubvaqoraolsmtjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pokerlamp 1', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pokerlamp_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.pokerlamp_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.z7cu5t8bl9tt9fabjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pokerlamp 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pokerlamp_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.porch_light_e-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.porch_light_e', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.klgxmpwvdhw7tzs8jdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.porch_light_e-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Porch light E', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.porch_light_e', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.sjiethoes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.sjiethoes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.rdq0bn4dzuwx2qfujdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.sjiethoes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sjiethoes', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.sjiethoes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.slaapkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.slaapkamer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ajkdo1bm2rcmpuufjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.slaapkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Slaapkamer', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.slaapkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.smart_bulb_rgbcw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.smart_bulb_rgbcw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.qhgghufzqtwloqoqjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.smart_bulb_rgbcw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Smart Bulb RGBCW', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.smart_bulb_rgbcw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.solar_zijpad-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.solar_zijpad', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.couukaypjdnytswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.solar_zijpad-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Solar zijpad', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.solar_zijpad', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.stairs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.stairs', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.zf8vgiwoa07jwegtjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.stairs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Stairs', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.stairs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.stoel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.stoel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.nxdcy0uidplnhkazjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.stoel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Stoel', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.stoel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.strip_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.strip_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bak2crzmabancwqvjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.strip_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Strip 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.strip_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.study_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.study_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.sifg4pfqsylsayg0jdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.study_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Study 1', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.study_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.tapparelle_studio_backlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.tapparelle_studio_backlight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.2w46jyhngklcswitch_backlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.tapparelle_studio_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tapparelle studio Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tapparelle_studio_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.tower_fan_ca_407g_smart_backlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.lflvu8cazha8af9jsklight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.tower_fan_ca_407g_smart_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tower Fan CA-407G Smart Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.wc_d1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.wc_d1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.6o148laaosbf0g4djdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.wc_d1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WC D1', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.wc_d1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr new file mode 100644 index 00000000000..1aa8c3dcca9 --- /dev/null +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -0,0 +1,1992 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[number.aqi_alarm_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqi_alarm_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_duration', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocalarm_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[number.aqi_alarm_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AQI Alarm duration', + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.aqi_alarm_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.boiler_temperature_controller_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.boiler_temperature_controller_temperature_correction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.zgiyrxflahjowpcckwtemp_correction', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.boiler_temperature_controller_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Boiler Temperature Controller Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.boiler_temperature_controller_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.8', + }) +# --- +# name: test_platform_setup_and_discovery[number.c9_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.c9_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_device_volume', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.c9_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Volume', + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.c9_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.cleverio_pf100_feed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 20.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.cleverio_pf100_feed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Feed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feed', + 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcmanual_feed', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.cleverio_pf100_feed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cleverio PF100 Feed', + 'max': 20.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.cleverio_pf100_feed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.hot_water_heat_pump_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75.0, + 'min': 15.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.hot_water_heat_pump_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ol8xwtcj42eg18bdbrnztemp_set', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[number.hot_water_heat_pump_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hot Water Heat Pump Temperature', + 'max': 75.0, + 'min': 15.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.hot_water_heat_pump_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_maximum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwymax_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_minimum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwymini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyinstallation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Installation height', + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.56', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[number.house_water_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Maximum liquid depth', + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_far_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_far_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Far detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'far_detection', + 'unique_id': 'tuya.kxwleaa2sphfar_detection', + 'unit_of_measurement': 'cm', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_far_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Human presence Office Far detection', + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cm', + }), + 'context': , + 'entity_id': 'number.human_presence_office_far_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_near_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_near_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Near detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'near_detection', + 'unique_id': 'tuya.kxwleaa2sphnear_detection', + 'unit_of_measurement': 'cm', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_near_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Human presence Office Near detection', + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cm', + }), + 'context': , + 'entity_id': 'number.human_presence_office_near_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity', + 'unique_id': 'tuya.kxwleaa2sphsensitivity', + 'unit_of_measurement': 'x', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Human presence Office Sensitivity', + 'max': 10.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'x', + }), + 'context': , + 'entity_id': 'number.human_presence_office_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_1', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 1', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_2', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 2', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_3', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 3', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_4', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 4', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_5', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 5', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_6', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 6', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 7', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_7', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 7', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration 8', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_irrigation_duration', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfscountdown_8', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.jie_hashui_fa_irrigation_duration_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '接HA水阀 Irrigation duration 8', + 'max': 1439.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.jie_hashui_fa_irrigation_duration_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.kabinet_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kabinet_temperature_correction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.dn7cjik6kwtemp_correction', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.kabinet_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Temperature correction', + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.kabinet_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_delay', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_delay_time', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Alarm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_arm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Arm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_delay', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamdelay_set', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_arm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Arm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_siren_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Siren duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_duration', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_siren_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Siren duration', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymax_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyinstallation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Installation height', + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.35', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Maximum liquid depth', + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_platform_setup_and_discovery[number.siren_veranda_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.siren_veranda_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time', + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_time', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.siren_veranda_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Siren veranda Time', + 'max': 30.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.siren_veranda_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.smart_thermostats_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.smart_thermostats_temperature_correction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.sb3zdertrw50bgogkwtemp_correction', + 'unit_of_measurement': '摄氏度', + }) +# --- +# name: test_platform_setup_and_discovery[number.smart_thermostats_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Temperature correction', + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '摄氏度', + }), + 'context': , + 'entity_id': 'number.smart_thermostats_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cook temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_temperature', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_temperature', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook temperature', + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cook time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook time', + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[number.v20_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.v20_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.zrrraytdoanz33rldsvolume_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.v20_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Volume', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.v20_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '95.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwtemp_correction', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.5', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr new file mode 100644 index 00000000000..fc238604ea3 --- /dev/null +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -0,0 +1,4365 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[select.3dprinter_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.3dprinter_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.pykascx9yfqrxtbgzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.3dprinter_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '3DPrinter Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.3dprinter_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.3dprinter_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.3dprinter_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.pykascx9yfqrxtbgzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.3dprinter_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '3DPrinter Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.3dprinter_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.4_433_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.4_433_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.4_433_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '4-433 Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.4_433_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.6294ha_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.6294ha_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.6294ha_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '6294HA Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.6294ha_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.aqi_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aqi_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocalarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aqi_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Volume', + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'context': , + 'entity_id': 'select.aqi_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_cooker_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.cju47ovcbeuapei2zclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Cooker Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_cooker_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_cooker_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.cju47ovcbeuapei2zcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Cooker Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_cooker_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_washing_machine_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Washing Machine Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_washing_machine_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_washing_machine_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Washing Machine Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_washing_machine_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.balkonbewasserung_weather_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.balkonbewasserung_weather_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'weather_delay', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsweather_delay', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.balkonbewasserung_weather_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'balkonbewässerung Weather delay', + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'context': , + 'entity_id': 'select.balkonbewasserung_weather_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bathroom_light_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.gluaktf5gklight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'bathroom light Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.bathroom_light_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pos', + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bathroom_light_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.gluaktf5gkrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'bathroom light Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.bathroom_light_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_off', + }) +# --- +# name: test_platform_setup_and_discovery[select.blinds_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'morning', + 'night', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.blinds_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_mode', + 'unique_id': 'tuya.nr26obpclcmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.blinds_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'blinds Mode', + 'options': list([ + 'morning', + 'night', + ]), + }), + 'context': , + 'entity_id': 'select.blinds_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'morning', + }) +# --- +# name: test_platform_setup_and_discovery[select.bree_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bree_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjkcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.bree_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'context': , + 'entity_id': 'select.bree_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_anti_flicker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_anti_flicker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Anti-flicker', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_anti_flicker', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_anti_flicker', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_anti_flicker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Anti-flicker', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_anti_flicker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_motion_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.svjjuwykgijjedurpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_night_vision-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_night_vision', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night vision', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_nightvision', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_nightvision', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_night_vision-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Night vision', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_night_vision', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_record_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.svjjuwykgijjedurpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_sound_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.svjjuwykgijjedurpsdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.c9_ipc_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_ipc_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IPC mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ipc_work_mode', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsipc_work_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.c9_ipc_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 IPC mode', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.c9_ipc_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.c9_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_motion_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.c9_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.c9_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_record_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.c9_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_motion_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_night_vision-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_night_vision', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night vision', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_nightvision', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_nightvision', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_night_vision-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Night vision', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_night_vision', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_record_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_sound_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_motion_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_record_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_sound_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.ceiling_fan_with_light_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.ijzjlqwmv1blwe0gsfcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.ceiling_fan_with_light_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ceiling Fan With Light Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'context': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifer_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifer_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.ifzgvpgoodrfw2aksccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifer_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifer_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifier_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifier_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.2myxayqtud9aqbizsccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_kitchen_socket_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco Kitchen Socket Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_kitchen_socket_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_kitchen_socket_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco Kitchen Socket Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_kitchen_socket_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_tv_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco TV Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_tv_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_tv_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco TV Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_tv_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisier_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.51tdkcsamisw9ukycplight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisier Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.framboisier_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisier_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.51tdkcsamisw9ukycprelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisier Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.framboisier_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisiers_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisiers_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisiers_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisiers Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.framboisiers_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.garage_camera_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garage_camera_motion_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.garage_camera_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.garage_camera_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.garage_camera_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garage_camera_record_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.53fnjncm3jywuaznpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.garage_camera_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.garage_camera_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.hoover_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'random', + 'smart', + 'wall_follow', + 'chargego', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.hoover_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_mode', + 'unique_id': 'tuya.mwsaod7fa3gjyh6idsmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.hoover_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hoover Mode', + 'options': list([ + 'random', + 'smart', + 'wall_follow', + 'chargego', + ]), + }), + 'context': , + 'entity_id': 'select.hoover_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'chargego', + }) +# --- +# name: test_platform_setup_and_discovery[select.ineox_sp2_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ineox_sp2_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.vx2owjsg86g2ys93zcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.ineox_sp2_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ineox SP2 Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.ineox_sp2_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.ion1000pro_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ion1000pro_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.owozxdzgbibizu4sjkcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.ion1000pro_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Countdown', + 'options': list([ + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'context': , + 'entity_id': 'select.ion1000pro_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.jardin_fraises_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.jardin_fraises_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'jardin Fraises Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.kalado_air_purifier_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kalado_air_purifier_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.kalado_air_purifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'context': , + 'entity_id': 'select.kalado_air_purifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.kitchen_blinds_motor_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'forward', + 'back', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motor mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_motor_mode', + 'unique_id': 'tuya.dke76hazlccontrol_back_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.kitchen_blinds_motor_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Blinds Motor mode', + 'options': list([ + 'forward', + 'back', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'forward', + }) +# --- +# name: test_platform_setup_and_discovery[select.lave_linge_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lave_linge_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.g0edqq0wzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.lave_linge_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lave linge Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.lave_linge_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_platform_setup_and_discovery[select.lave_linge_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lave_linge_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.g0edqq0wzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.lave_linge_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lave linge Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.lave_linge_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.office_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.office_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.2x473nefusdo7af6zclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.office_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.office_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.office_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.office_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.2x473nefusdo7af6zcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.office_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.office_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.persiana_do_quarto_motor_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'forward', + 'back', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.persiana_do_quarto_motor_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motor mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_motor_mode', + 'unique_id': 'tuya.ikbbdbnqsd70pc1glccontrol_back_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.persiana_do_quarto_motor_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Persiana do Quarto Motor mode', + 'options': list([ + 'forward', + 'back', + ]), + }), + 'context': , + 'entity_id': 'select.persiana_do_quarto_motor_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'back', + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.raspy4_home_assistant_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.zaszonjgzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Raspy4 - Home Assistant Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.raspy4_home_assistant_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.raspy4_home_assistant_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.zaszonjgzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Raspy4 - Home Assistant Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.raspy4_home_assistant_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_light_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Light Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.security_light_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_light_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Light Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.security_light_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.siren_veranda_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.siren_veranda_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.siren_veranda_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Siren veranda Volume', + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'context': , + 'entity_id': 'select.siren_veranda_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[select.smart_odor_eliminator_pro_odor_elimination_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Odor elimination mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_mode', + 'unique_id': 'tuya.rl39uwgaqwjwcwork_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.smart_odor_eliminator_pro_odor_elimination_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Odor elimination mode', + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'context': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.socket3_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.socket3_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.socket3_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket3 Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.socket3_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.socket4_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.socket4_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.socket4_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket4 Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.socket4_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.socket4_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.socket4_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.socket4_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket4 Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.socket4_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.spot_1_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.kffnst1epj6vr8xnzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spot 1 Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.spot_1_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.spot_1_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.kffnst1epj6vr8xnzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spot 1 Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.spot_1_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.sunbeam_bedding_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer-lines', + 'original_name': 'Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blanket_level', + 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sunbeam Bedding Level', + 'icon': 'mdi:thermometer-lines', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'context': , + 'entity_id': 'select.sunbeam_bedding_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_5', + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer-lines', + 'original_name': 'Side A Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blanket_level', + 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sunbeam Bedding Side A Level', + 'icon': 'mdi:thermometer-lines', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'context': , + 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_5', + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer-lines', + 'original_name': 'Side B Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blanket_level', + 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sunbeam Bedding Side B Level', + 'icon': 'mdi:thermometer-lines', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'context': , + 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_5', + }) +# --- +# name: test_platform_setup_and_discovery[select.v20_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_mode', + 'unique_id': 'tuya.zrrraytdoanz33rldsmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.v20_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Mode', + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'context': , + 'entity_id': 'select.v20_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.v20_water_tank_adjustment-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_water_tank_adjustment', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water tank adjustment', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_cistern', + 'unique_id': 'tuya.zrrraytdoanz33rldscistern', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.v20_water_tank_adjustment-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Water tank adjustment', + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.v20_water_tank_adjustment', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[select.valve_controller_2_weather_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.valve_controller_2_weather_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'weather_delay', + 'unique_id': 'tuya.kx8dncf1qzkfsweather_delay', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.valve_controller_2_weather_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve Controller 2 Weather delay', + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'context': , + 'entity_id': 'select.valve_controller_2_weather_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wallwasher_front_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.pdasfna8fswh4a0tzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.wallwasher_front_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wallwasher_front_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.wallwasher_front_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.weihnachtsmann_indicator_light_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.rwp6kdezm97s2nktzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachtsmann Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.weihnachtsmann_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.weihnachtsmann_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.rwp6kdezm97s2nktzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachtsmann Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.weihnachtsmann_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..0baf85f05b6 --- /dev/null +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -0,0 +1,14122 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sensor.3dprinter_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.3dprinter_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.pykascx9yfqrxtbgzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '3DPrinter Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3dprinter_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.3dprinter_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.pykascx9yfqrxtbgzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '3DPrinter Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.3dprinter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.3dprinter_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.pykascx9yfqrxtbgzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '3DPrinter Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3dprinter_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.6294ha_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.q62sg0p3s52thp6zzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '6294HA Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.6294ha_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.466', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.6294ha_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.q62sg0p3s52thp6zzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '6294HA Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.6294ha_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11374.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.6294ha_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.q62sg0p3s52thp6zzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '6294HA Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.6294ha_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aqi_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'AQI Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_formaldehyde-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_formaldehyde', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Formaldehyde', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'formaldehyde', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2occh2o_value', + 'unit_of_measurement': 'mg/m3', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_formaldehyde-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Formaldehyde', + 'state_class': , + 'unit_of_measurement': 'mg/m3', + }), + 'context': , + 'entity_id': 'sensor.aqi_formaldehyde', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ochumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AQI Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2octemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AQI Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aqi_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_volatile_organic_compounds-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aqi_volatile_organic_compounds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voc', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocvoc_value', + 'unit_of_measurement': 'mg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aqi_volatile_organic_compounds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds', + 'friendly_name': 'AQI Volatile organic compounds', + 'state_class': , + 'unit_of_measurement': 'mg/m³', + }), + 'context': , + 'entity_id': 'sensor.aqi_volatile_organic_compounds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.018', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aubess_cooker_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.cju47ovcbeuapei2zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Aubess Cooker Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_cooker_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aubess_cooker_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.cju47ovcbeuapei2zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aubess Cooker Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aubess_cooker_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aubess_cooker_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.cju47ovcbeuapei2zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aubess Cooker Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_cooker_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aubess_washing_machine_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Aubess Washing Machine Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_washing_machine_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aubess_washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aubess Washing Machine Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aubess_washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aubess_washing_machine_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aubess Washing Machine Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_washing_machine_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.balkonbewasserung_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.balkonbewasserung_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.balkonbewasserung_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'balkonbewässerung Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.balkonbewasserung_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_temperature_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.jlduh7vigcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Basement temperature Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_temperature_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.jlduh7vigcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Basement temperature Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_temperature_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.jlduh7vigcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement temperature Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.basement_temperature_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bassin_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.iaagy4qigcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bassin Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bassin_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bassin_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Bassin Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bassin_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.783', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bassin_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Bassin Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.bassin_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bassin_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.iaagy4qigcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bassin Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bassin_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bassin_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Bassin Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bassin_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '245.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bathroom_smart_switch_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bathroom_smart_switch_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bathroom_smart_switch_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bathroom Smart Switch Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bathroom_smart_switch_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.boite_aux_lettres_arriere_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.boite_aux_lettres_arriere_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.7obpyhy8scmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.boite_aux_lettres_arriere_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Boîte aux lettres - arrière Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.boite_aux_lettres_arriere_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bouton_tempo_exterieur_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bouton_tempo_exterieur_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.g5uso5ajgkxwbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bouton_tempo_exterieur_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bouton tempo extérieur Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bouton_tempo_exterieur_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air pressure', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_pressure', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqatmospheric_pressture', + 'unit_of_measurement': 'hPa', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Air pressure', + 'state_class': , + 'unit_of_measurement': 'hPa', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1004.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Battery state', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_outdoor', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor_3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_precipitation_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_precipitation_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation intensity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_intensity', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqrain_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_precipitation_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'precipitation_intensity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Precipitation intensity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_precipitation_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external_1', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external_2', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external_3', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_total_precipitation_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_total_precipitation_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total precipitation today', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_today', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqrain_24h', + 'unit_of_measurement': 'mm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_total_precipitation_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'precipitation', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Total precipitation today', + 'state_class': , + 'unit_of_measurement': 'mm', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_total_precipitation_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV index', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxquv_index', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit UV index', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqwind_direct', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Wind direction', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqwindspeed_avg', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.c9_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.c9_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspswireless_electricity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.c9_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'C9 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.c9_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cleverio_pf100_last_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cleverio_pf100_last_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last amount', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_amount', + 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcfeed_report', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cleverio_pf100_last_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cleverio PF100 Last amount', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.cleverio_pf100_last_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.consommation_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Consommation Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.consommation_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.585', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.consommation_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Consommation Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.consommation_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '425.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.consommation_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Consommation Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.consommation_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '241.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifer_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dehumidifer_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ifzgvpgoodrfw2akschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifer_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifer Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifer_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifier_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dehumidifier_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.2myxayqtud9aqbizschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifier_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifier Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifier_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.door_garage_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.door_garage_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bFFsO8HimyAJGIj7scmbattery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.door_garage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Door Garage Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.door_garage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.droger_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'droger Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.754', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.droger_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'droger Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.droger_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '593.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.droger_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'droger Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '222.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '断路器HA Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '599.296', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '断路器HA Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.432', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '断路器HA Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_supply_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.duan_lu_qi_ha_supply_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply frequency', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_frequency', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldsupply_frequency', + 'unit_of_measurement': 'Hz', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_supply_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': '断路器HA Supply frequency', + 'state_class': , + 'unit_of_measurement': 'Hz', + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_supply_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eau_chaude_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Eau Chaude Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eau_chaude_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.067', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eau_chaude_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Eau Chaude Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.eau_chaude_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2441.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eau_chaude_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eau Chaude Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eau_chaude_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '241.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Edesanya Energy Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.608', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Edesanya Energy Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.133', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Edesanya Energy Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Edesanya Energy Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Edesanya Energy Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Edesanya Energy Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Edesanya Energy Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Edesanya Energy Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Edesanya Energy Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.edesanya_energy_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldtotal_forward_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Edesanya Energy Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '219.72', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elivco_kitchen_socket_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Elivco Kitchen Socket Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_kitchen_socket_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elivco_kitchen_socket_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Elivco Kitchen Socket Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.elivco_kitchen_socket_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elivco_kitchen_socket_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Elivco Kitchen Socket Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_kitchen_socket_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elivco_tv_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Elivco TV Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_tv_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.091', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elivco_tv_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Elivco TV Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.elivco_tv_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elivco_tv_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Elivco TV Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_tv_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '237.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.fenetre_cuisine_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fenetre_cuisine_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.yuanswy6scmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.fenetre_cuisine_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Fenêtre cuisine Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fenetre_cuisine_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '93.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.framboisier_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.51tdkcsamisw9ukycpcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Framboisier Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.framboisier_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.framboisier_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.51tdkcsamisw9ukycpcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Framboisier Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.framboisier_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.framboisier_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.51tdkcsamisw9ukycpcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Framboisier Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.framboisier_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '247.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frysen_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.ase6htln9tdni2sijxqbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frysen Battery state', + }), + 'context': , + 'entity_id': 'sensor.frysen_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ase6htln9tdni2sijxqhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Frysen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frysen_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_probe_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.ase6htln9tdni2sijxqtemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-13.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ase6htln9tdni2sijxqtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_contact_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_contact_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.3uqk1csjqplf3uxqscmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_contact_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Contact Sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garage_contact_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_socket_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.3d4yosotwk27nqxvzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Garage Socket Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garage_socket_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_socket_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.3d4yosotwk27nqxvzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Garage Socket Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.garage_socket_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_socket_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.3d4yosotwk27nqxvzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Garage Socket Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garage_socket_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garaz_cerpadlo_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.wc6mumew8inrivi9zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Garáž čerpadlo Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garaz_cerpadlo_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garaz_cerpadlo_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.wc6mumew8inrivi9zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Garáž čerpadlo Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.garaz_cerpadlo_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garaz_cerpadlo_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.wc6mumew8inrivi9zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Garáž čerpadlo Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garaz_cerpadlo_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '240.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.gas_sensor_gas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_sensor_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas', + 'unique_id': 'tuya.cwwk68dyfsh2eqi4jbqrgas_sensor_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.gas_sensor_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.gas_sensor_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.greenhouse_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.ggwxkj8bwn5y63flgcdswbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Greenhouse Battery state', + }), + 'context': , + 'entity_id': 'sensor.greenhouse_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.greenhouse_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ggwxkj8bwn5y63flgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Greenhouse Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.greenhouse_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.greenhouse_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ggwxkj8bwn5y63flgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Greenhouse Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.greenhouse_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hl400_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hl400_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkpm25', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hl400_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 PM2.5', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.hl400_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hot_water_heat_pump_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hot_water_heat_pump_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ol8xwtcj42eg18bdbrnztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hot_water_heat_pump_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hot Water Heat Pump Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hot_water_heat_pump_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.42', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_liquid_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_liquid_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'upper_alarm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.humy_bain_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.ZgXzZULP6dDp4Atvgcdswva_battery', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humy bain Battery', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.humy_bain_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humy_bain_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ZgXzZULP6dDp4Atvgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humy bain Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humy_bain_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '63.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humy_bain_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ZgXzZULP6dDp4Atvgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Humy bain Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.humy_bain_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.humy_toilettes_rdc_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.69dth3rxgcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Humy toilettes RDC Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humy_toilettes_rdc_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humy_toilettes_rdc_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.69dth3rxgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humy toilettes RDC Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humy_toilettes_rdc_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humy_toilettes_rdc_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.69dth3rxgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Humy toilettes RDC Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.humy_toilettes_rdc_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hvac_meter_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'HVAC Meter Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.083', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hvac_meter_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'HVAC Meter Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hvac_meter_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'HVAC Meter Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '121.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ifs_std002_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.9wlo8cpzprhiclrkgcdswbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'IFS-STD002 Battery state', + }), + 'context': , + 'entity_id': 'sensor.ifs_std002_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ifs_std002_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.9wlo8cpzprhiclrkgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'IFS-STD002 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ifs_std002_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ifs_std002_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.9wlo8cpzprhiclrkgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'IFS-STD002 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ifs_std002_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ineox_sp2_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.vx2owjsg86g2ys93zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Ineox SP2 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ineox_sp2_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.228', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ineox_sp2_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.vx2owjsg86g2ys93zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ineox SP2 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.ineox_sp2_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ineox_sp2_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.vx2owjsg86g2ys93zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Ineox SP2 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ineox_sp2_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ion1000pro_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ion1000pro_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.owozxdzgbibizu4sjkpm25', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ion1000pro_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO PM2.5', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.ion1000pro_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.jie_hashui_fa_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '接HA水阀 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.jie_hashui_fa_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.jie_hashui_fa_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.jie_hashui_fa_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '接HA水阀 Battery state', + }), + 'context': , + 'entity_id': 'sensor.jie_hashui_fa_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kalado_air_purifier_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air quality', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkair_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Air quality', + }), + 'context': , + 'entity_id': 'sensor.kalado_air_purifier_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'great', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_filter_utilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kalado_air_purifier_filter_utilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter utilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_utilization', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkfilter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_filter_utilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Filter utilization', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kalado_air_purifier_filter_utilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.keller_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.g7af6lrt4miugbstcpcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Keller Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.keller_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.keller_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.g7af6lrt4miugbstcpcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Keller Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.keller_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.keller_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.g7af6lrt4miugbstcpcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Keller Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.keller_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kippenluik_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kippenluik_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.AUTwCwqDY9EjlQSocmbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kippenluik_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kippenluik Battery state', + }), + 'context': , + 'entity_id': 'sensor.kippenluik_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lave_linge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.g0edqq0wzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Lave linge Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lave_linge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lave_linge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.g0edqq0wzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Lave linge Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.lave_linge_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lave_linge_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.g0edqq0wzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Lave linge Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lave_linge_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '244.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.licht_drucker_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Licht drucker Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.licht_drucker_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.licht_drucker_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Licht drucker Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.licht_drucker_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.licht_drucker_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Licht drucker Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.licht_drucker_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lounge_dark_blind_last_operation_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last operation duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_operation_duration', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lctime_total', + 'unit_of_measurement': 'ms', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Last operation duration', + 'unit_of_measurement': 'ms', + }), + 'context': , + 'entity_id': 'sensor.lounge_dark_blind_last_operation_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25400.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.luminosite_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.luminosite_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.ohefbbk9gcdlbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.luminosite_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Luminosité Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.luminosite_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '91.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.luminosite_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luminosite_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.ohefbbk9gcdlbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.luminosite_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Luminosité Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.luminosite_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Meter Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.62', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Meter Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.185', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Meter Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.637', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.108', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '221.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.203', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.41', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.913', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.092', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_supply_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_supply_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply frequency', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_frequency', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldsupply_frequency', + 'unit_of_measurement': 'Hz', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_supply_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Metering_3PN_WiFi_stable Supply frequency', + 'state_class': , + 'unit_of_measurement': 'Hz', + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_supply_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.motion_sensor_lidl_zigbee_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.motion_sensor_lidl_zigbee_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.s3zzjdcfripbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.motion_sensor_lidl_zigbee_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Motion sensor lidl zigbee Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.motion_sensor_lidl_zigbee_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.np_downstairs_north_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'NP DownStairs North Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.np_downstairs_north_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'NP DownStairs North Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.np_downstairs_north_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'NP DownStairs North Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.2x473nefusdo7af6zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Office Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.253', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.2x473nefusdo7af6zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Office Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.office_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.2x473nefusdo7af6zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Office Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'P1 Energia Elettrica Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Energia Elettrica Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.314', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Energia Elettrica Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '215.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'P1 Energia Elettrica Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Energia Elettrica Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Energia Elettrica Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'P1 Energia Elettrica Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Energia Elettrica Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Energia Elettrica Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.p1_energia_elettrica_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldtotal_forward_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Energia Elettrica Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22799.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.patates_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.uew54dymycjwzbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Patates Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.patates_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.uew54dymycjwzbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Patates Battery state', + }), + 'context': , + 'entity_id': 'sensor.patates_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.patates_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.uew54dymycjwzhumidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Patates Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.patates_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.uew54dymycjwztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Patates Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.patates_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_2_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pid_relay_2_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'pid_relay_2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pid_relay_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pid_relay_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'pid_relay_2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pid_relay_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pir_outside_stairs_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pir_outside_stairs_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.zoytcemodrn39zqwripbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pir_outside_stairs_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIR outside stairs Battery state', + }), + 'context': , + 'entity_id': 'sensor.pir_outside_stairs_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_filter_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pixi_smart_drinking_fountain_filter_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_duration', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_life', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_filter_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain Filter duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_filter_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18965.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_uv_runtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pixi_smart_drinking_fountain_uv_runtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'UV runtime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_runtime', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv_runtime', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_uv_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain UV runtime', + 'state_class': , + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_uv_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level_state', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water level', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_3', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_pump_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_pump_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water pump duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_time', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_pump_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain Water pump duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_pump_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18965.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_usage_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_usage_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water usage duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_time', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_usage_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain Water usage duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_usage_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.production_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnztotal_powerpower', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Production Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.production_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2314.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.production_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnzforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Production Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.production_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1520.21', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.production_total_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total production', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_production', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnzreverse_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Production Total production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.production_total_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.455', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_liquid_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.raspy4_home_assistant_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.zaszonjgzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Raspy4 - Home Assistant Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.raspy4_home_assistant_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.033', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.raspy4_home_assistant_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.zaszonjgzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Raspy4 - Home Assistant Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.raspy4_home_assistant_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.raspy4_home_assistant_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.zaszonjgzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Raspy4 - Home Assistant Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.raspy4_home_assistant_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '244.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rat_trap_hedge_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rat_trap_hedge_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.hkm4px9ohzozxma3ripbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rat_trap_hedge_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'rat trap hedge Battery state', + }), + 'context': , + 'entity_id': 'sensor.rat_trap_hedge_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_alexsandro_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rauchmelder_alexsandro_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.o71einxvuuktuljcjbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_alexsandro_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rauchmelder Alexsandro Battery state', + }), + 'context': , + 'entity_id': 'sensor.rauchmelder_alexsandro_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_drucker_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rauchmelder_drucker_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.t5zosev6h6wmwyrajbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_drucker_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rauchmelder Drucker Battery state', + }), + 'context': , + 'entity_id': 'sensor.rauchmelder_drucker_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sapphire_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.hfqeljop3aihnm73zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Sapphire Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sapphire_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.135', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sapphire_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.hfqeljop3aihnm73zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Sapphire Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.sapphire_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '313.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sapphire_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.hfqeljop3aihnm73zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Sapphire Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sapphire_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2357.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.rl39uwgaqwjwcbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smart Odor Eliminator-Pro Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_status', + 'unique_id': 'tuya.rl39uwgaqwjwcwork_state_e', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Status', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smogo_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smogo_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.swhtzki3qrz5ydchjbocbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smogo_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smogo Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smogo_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_alarm_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.p8xoxccrjbwybattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smoke Alarm Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smoke_alarm_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_alarm_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.p8xoxccrjbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Alarm Battery state', + }), + 'context': , + 'entity_id': 'sensor.smoke_alarm_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_smoke_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_alarm_smoke_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smoke amount', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smoke_amount', + 'unique_id': 'tuya.p8xoxccrjbwysmoke_sensor_value', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_alarm_smoke_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Alarm Smoke amount', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smoke_alarm_smoke_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': ' Smoke detector upstairs Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': ' Smoke detector upstairs Battery state', + }), + 'context': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.socket3_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Socket3 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.socket3_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Socket3 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.socket3_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.socket3_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Socket3 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.socket4_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Socket4 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket4_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.socket4_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Socket4 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.socket4_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.socket4_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Socket4 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket4_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solar_zijpad_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.couukaypjdnytbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Solar zijpad Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solar_zijpad_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solar_zijpad_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.couukaypjdnytbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Solar zijpad Battery state', + }), + 'context': , + 'entity_id': 'sensor.solar_zijpad_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_current_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Sous Vide Current temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmremain_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sous_vide_status', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmstatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Status', + }), + 'context': , + 'entity_id': 'sensor.sous_vide_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spa_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.gi69tunb0esxcnefzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Spa Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spa_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.404', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.gi69tunb0esxcnefzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Spa Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.spa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1201.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spa_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.gi69tunb0esxcnefzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Spa Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spa_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '238.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.steel_cage_door_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.steel_cage_door_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.gvxxy4jitzltz5xhscmbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.steel_cage_door_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Steel cage door Battery state', + }), + 'context': , + 'entity_id': 'sensor.steel_cage_door_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.tournesol_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.tournesol_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.codvtvgtjsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.tournesol_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Tournesol Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.tournesol_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.v20_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.zrrraytdoanz33rldselectricity_left', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'V20 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.v20_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'tuya.zrrraytdoanz33rldsclean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'tuya.zrrraytdoanz33rldsclean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_duster_cloth_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_duster_cloth_lifetime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Duster cloth lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duster_cloth_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsduster_cloth', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_duster_cloth_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Duster cloth lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_duster_cloth_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9000.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_filter_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_filter_lifetime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsfilter', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_filter_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Filter lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_filter_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8956.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_rolling_brush_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_rolling_brush_lifetime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rolling brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rolling_brush_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsroll_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_rolling_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Rolling brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_rolling_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17948.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_side_brush_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_side_brush_lifetime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Side brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsedge_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_side_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Side brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_side_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8944.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_total_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_times-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_total_cleaning_times', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning times', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_times', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_times-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning times', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_times', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.valve_controller_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.kx8dncf1qzkfsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Valve Controller 2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.valve_controller_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_2_total_watering_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.valve_controller_2_total_watering_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total watering time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_watering_time', + 'unique_id': 'tuya.kx8dncf1qzkfstime_use', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_2_total_watering_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve Controller 2 Total watering time', + 'state_class': , + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'sensor.valve_controller_2_total_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.varmelampa_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.sw1ejdomlmfubapizccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Värmelampa Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.varmelampa_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.435', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.varmelampa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.sw1ejdomlmfubapizccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Värmelampa Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.varmelampa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1642.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.varmelampa_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.sw1ejdomlmfubapizccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Värmelampa Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.varmelampa_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '224.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_fountain_filter_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_duration', + 'unique_id': 'tuya.q304vac40br8nlkajsywcfilter_life', + 'unit_of_measurement': 'day', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Filter duration', + 'state_class': , + 'unit_of_measurement': 'day', + }), + 'context': , + 'entity_id': 'sensor.water_fountain_filter_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_water_pump_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_fountain_water_pump_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water pump duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_time', + 'unique_id': 'tuya.q304vac40br8nlkajsywcpump_time', + 'unit_of_measurement': 'day', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_water_pump_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Water pump duration', + 'state_class': , + 'unit_of_measurement': 'day', + }), + 'context': , + 'entity_id': 'sensor.water_fountain_water_pump_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weihnachtsmann_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.rwp6kdezm97s2nktzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Weihnachtsmann Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachtsmann_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weihnachtsmann_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.rwp6kdezm97s2nktzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Weihnachtsmann Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.weihnachtsmann_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.weihnachtsmann_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.rwp6kdezm97s2nktzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Weihnachtsmann Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachtsmann_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wi_fi_solar_grid_micro_inverter_gt_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wi_fi_solar_grid_micro_inverter_gt_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.qifhbafbqubbp3b6qbnnzpower_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wi_fi_solar_grid_micro_inverter_gt_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wi-Fi solar grid micro inverter (GT) Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wi_fi_solar_grid_micro_inverter_gt_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wi_fi_solar_grid_micro_inverter_gt_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wi_fi_solar_grid_micro_inverter_gt_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.qifhbafbqubbp3b6qbnnztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wi_fi_solar_grid_micro_inverter_gt_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Wi-Fi solar grid micro inverter (GT) Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wi_fi_solar_grid_micro_inverter_gt_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wi_fi_solar_grid_micro_inverter_gt_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wi_fi_solar_grid_micro_inverter_gt_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.qifhbafbqubbp3b6qbnnzreverse_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wi_fi_solar_grid_micro_inverter_gt_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wi-Fi solar grid micro inverter (GT) Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wi_fi_solar_grid_micro_inverter_gt_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.19', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_smart_gas_boiler_thermostat_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wifi_smart_gas_boiler_thermostat_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_smart_gas_boiler_thermostat_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_smart_gas_boiler_thermostat_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_smoke_alarm_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wifi_smoke_alarm_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.tvgoe1s3fabebcskjbwybattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_smoke_alarm_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WIFI Smoke alarm Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_smoke_alarm_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WiFi Temperature & Humidity Sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Temperature & Humidity Sensor Battery state', + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'WiFi Temperature & Humidity Sensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'WiFi Temperature & Humidity Sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '599.552', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.912', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_supply_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_supply_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply frequency', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supply_frequency', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzsupply_frequency', + 'unit_of_measurement': 'Hz', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_supply_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Supply frequency', + 'state_class': , + 'unit_of_measurement': 'Hz', + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_supply_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total production', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_production', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzreverse_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Total production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '一路带计量磁保持通断器 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.198', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '一路带计量磁保持通断器 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '495.3', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '一路带计量磁保持通断器 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.4', + }) +# --- diff --git a/tests/components/linear_garage_door/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_siren.ambr similarity index 59% rename from tests/components/linear_garage_door/snapshots/test_cover.ambr rename to tests/components/tuya/snapshots/test_siren.ambr index dc3df6684bc..c907d94dc39 100644 --- a/tests/components/linear_garage_door/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_covers[cover.test_garage_1-entry] +# name: test_platform_setup_and_discovery[siren.aqi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10,9 +10,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.test_garage_1', + 'domain': 'siren', + 'entity_category': , + 'entity_id': 'siren.aqi', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -22,34 +22,33 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': None, - 'platform': 'linear_garage_door', + 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'test1-GDO', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocalarm_switch', 'unit_of_measurement': None, }) # --- -# name: test_covers[cover.test_garage_1-state] +# name: test_platform_setup_and_discovery[siren.aqi-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 1', - 'supported_features': , + 'friendly_name': 'AQI', + 'supported_features': , }), 'context': , - 'entity_id': 'cover.test_garage_1', + 'entity_id': 'siren.aqi', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'off', }) # --- -# name: test_covers[cover.test_garage_2-entry] +# name: test_platform_setup_and_discovery[siren.burocam-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,9 +59,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'cover', + 'domain': 'siren', 'entity_category': None, - 'entity_id': 'cover.test_garage_2', + 'entity_id': 'siren.burocam', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -72,34 +71,33 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': None, - 'platform': 'linear_garage_door', + 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'test2-GDO', + 'unique_id': 'tuya.svjjuwykgijjedurpssiren_switch', 'unit_of_measurement': None, }) # --- -# name: test_covers[cover.test_garage_2-state] +# name: test_platform_setup_and_discovery[siren.burocam-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 2', - 'supported_features': , + 'friendly_name': 'Bürocam', + 'supported_features': , }), 'context': , - 'entity_id': 'cover.test_garage_2', + 'entity_id': 'siren.burocam', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'closed', + 'state': 'off', }) # --- -# name: test_covers[cover.test_garage_3-entry] +# name: test_platform_setup_and_discovery[siren.c9-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -110,9 +108,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'cover', + 'domain': 'siren', 'entity_category': None, - 'entity_id': 'cover.test_garage_3', + 'entity_id': 'siren.c9', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -122,34 +120,33 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': None, - 'platform': 'linear_garage_door', + 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'test3-GDO', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspssiren_switch', 'unit_of_measurement': None, }) # --- -# name: test_covers[cover.test_garage_3-state] +# name: test_platform_setup_and_discovery[siren.c9-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 3', - 'supported_features': , + 'friendly_name': 'C9', + 'supported_features': , }), 'context': , - 'entity_id': 'cover.test_garage_3', + 'entity_id': 'siren.c9', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'off', }) # --- -# name: test_covers[cover.test_garage_4-entry] +# name: test_platform_setup_and_discovery[siren.siren_veranda-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -160,9 +157,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'cover', + 'domain': 'siren', 'entity_category': None, - 'entity_id': 'cover.test_garage_4', + 'entity_id': 'siren.siren_veranda', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -172,30 +169,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': None, - 'platform': 'linear_garage_door', + 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'test4-GDO', + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_switch', 'unit_of_measurement': None, }) # --- -# name: test_covers[cover.test_garage_4-state] +# name: test_platform_setup_and_discovery[siren.siren_veranda-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 4', - 'supported_features': , + 'friendly_name': 'Siren veranda ', + 'supported_features': , }), 'context': , - 'entity_id': 'cover.test_garage_4', + 'entity_id': 'siren.siren_veranda', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'closing', + 'state': 'off', }) # --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr new file mode 100644 index 00000000000..147c18e9e2a --- /dev/null +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -0,0 +1,8723 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[switch.3dprinter_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.3dprinter_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.pykascx9yfqrxtbgzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.3dprinter_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '3DPrinter Child lock', + }), + 'context': , + 'entity_id': 'switch.3dprinter_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.3dprinter_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3dprinter_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pykascx9yfqrxtbgzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.3dprinter_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '3DPrinter Socket 1', + }), + 'context': , + 'entity_id': 'switch.3dprinter_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.4_433_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 1', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.4_433_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 2', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.4_433_switch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 3', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.4_433_switch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.4_433_switch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 4', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.6294ha_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '6294HA Child lock', + }), + 'context': , + 'entity_id': 'switch.6294ha_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.6294ha_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '6294HA Socket 1', + }), + 'context': , + 'entity_id': 'switch.6294ha_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.6294ha_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '6294HA Socket 2', + }), + 'context': , + 'entity_id': 'switch.6294ha_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ac_charging_control_box_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ac_charging_control_box_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.qyy1auihjyoogvb7zdccqswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ac_charging_control_box_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC charging control box Switch', + }), + 'context': , + 'entity_id': 'switch.ac_charging_control_box_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.apollo_light_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.apollo_light_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.ncl7oi5d6hqmf1g0zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.apollo_light_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Apollo light Socket 1', + }), + 'context': , + 'entity_id': 'switch.apollo_light_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aubess_cooker_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.cju47ovcbeuapei2zcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Cooker Child lock', + }), + 'context': , + 'entity_id': 'switch.aubess_cooker_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.aubess_cooker_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.cju47ovcbeuapei2zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Aubess Cooker Socket 1', + }), + 'context': , + 'entity_id': 'switch.aubess_cooker_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aubess_washing_machine_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Washing Machine Child lock', + }), + 'context': , + 'entity_id': 'switch.aubess_washing_machine_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.aubess_washing_machine_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Aubess Washing Machine Socket 1', + }), + 'context': , + 'entity_id': 'switch.aubess_washing_machine_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.auvelico_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.auvelico_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.hxbonj4yzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.auvelico_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'AuVeLiCo Socket 1', + }), + 'context': , + 'entity_id': 'switch.auvelico_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.balkonbewasserung_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.balkonbewasserung_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.balkonbewasserung_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'balkonbewässerung Switch', + }), + 'context': , + 'entity_id': 'switch.balkonbewasserung_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bassin_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bassin_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bassin_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bassin Socket 1', + }), + 'context': , + 'entity_id': 'switch.bassin_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_fan_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bathroom_fan_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.5gfyvvg48bsxbbnjzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_fan_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bathroom Fan Socket 1', + }), + 'context': , + 'entity_id': 'switch.bathroom_fan_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_light_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bathroom_light_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.gluaktf5gkswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_light_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'bathroom light Switch 1', + }), + 'context': , + 'entity_id': 'switch.bathroom_light_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_mirror_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bathroom_mirror_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.eway2kw92ncuecarzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_mirror_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bathroom Mirror Socket 1', + }), + 'context': , + 'entity_id': 'switch.bathroom_mirror_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bree_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bree_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjkswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bree_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree Power', + }), + 'context': , + 'entity_id': 'switch.bree_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bubbelbad_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bubbelbad Socket 1', + }), + 'context': , + 'entity_id': 'switch.bubbelbad_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bubbelbad_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bubbelbad Socket 2', + }), + 'context': , + 'entity_id': 'switch.bubbelbad_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.buitenverlichting_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.buitenverlichting_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.2k8wyjo7iidkohuczcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.buitenverlichting_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Buitenverlichting Socket 1', + }), + 'context': , + 'entity_id': 'switch.buitenverlichting_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_flip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Flip', + }), + 'context': , + 'entity_id': 'switch.burocam_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_motion_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.svjjuwykgijjedurpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Motion alarm', + }), + 'context': , + 'entity_id': 'switch.burocam_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_motion_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.svjjuwykgijjedurpsmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Motion tracking', + }), + 'context': , + 'entity_id': 'switch.burocam_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_privacy_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_privacy_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_private', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Privacy mode', + }), + 'context': , + 'entity_id': 'switch.burocam_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_sound_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_sound_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.svjjuwykgijjedurpsdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Sound detection', + }), + 'context': , + 'entity_id': 'switch.burocam_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_time_watermark-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_time_watermark', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Time watermark', + }), + 'context': , + 'entity_id': 'switch.burocam_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_video_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_video_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.svjjuwykgijjedurpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Video recording', + }), + 'context': , + 'entity_id': 'switch.burocam_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_flip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Flip', + }), + 'context': , + 'entity_id': 'switch.c9_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_motion_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion alarm', + }), + 'context': , + 'entity_id': 'switch.c9_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_motion_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion recording', + }), + 'context': , + 'entity_id': 'switch.c9_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_motion_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion tracking', + }), + 'context': , + 'entity_id': 'switch.c9_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_time_watermark-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_time_watermark', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Time watermark', + }), + 'context': , + 'entity_id': 'switch.c9_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_video_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_video_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Video recording', + }), + 'context': , + 'entity_id': 'switch.c9_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_wide_dynamic_range-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_wide_dynamic_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wide dynamic range', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wide_dynamic_range', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_wdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_wide_dynamic_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Wide dynamic range', + }), + 'context': , + 'entity_id': 'switch.c9_wide_dynamic_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_garage_flip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Flip', + }), + 'context': , + 'entity_id': 'switch.cam_garage_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_motion_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_garage_motion_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Motion alarm', + }), + 'context': , + 'entity_id': 'switch.cam_garage_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_sound_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_garage_sound_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Sound detection', + }), + 'context': , + 'entity_id': 'switch.cam_garage_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_time_watermark-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_garage_time_watermark', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Time watermark', + }), + 'context': , + 'entity_id': 'switch.cam_garage_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_video_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_garage_video_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Video recording', + }), + 'context': , + 'entity_id': 'switch.cam_garage_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_porch_flip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Flip', + }), + 'context': , + 'entity_id': 'switch.cam_porch_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_motion_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_porch_motion_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Motion alarm', + }), + 'context': , + 'entity_id': 'switch.cam_porch_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_sound_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_porch_sound_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Sound detection', + }), + 'context': , + 'entity_id': 'switch.cam_porch_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_time_watermark-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_porch_time_watermark', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Time watermark', + }), + 'context': , + 'entity_id': 'switch.cam_porch_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_video_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_porch_video_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Video recording', + }), + 'context': , + 'entity_id': 'switch.cam_porch_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ceiling_fan_light_v2_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ceiling_fan_light_v2_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound', + 'unique_id': 'tuya.6wxksqu35c61sce9dsffan_beep', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ceiling_fan_light_v2_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ceiling fan/Light v2 Sound', + }), + 'context': , + 'entity_id': 'switch.ceiling_fan_light_v2_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.clima_cucina_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.clima_cucina_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.x7quooqakwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.clima_cucina_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clima cucina Child lock', + }), + 'context': , + 'entity_id': 'switch.clima_cucina_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.consommation_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Consommation Socket 1', + }), + 'context': , + 'entity_id': 'switch.consommation_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.consommation_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzcswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Consommation Socket 2', + }), + 'context': , + 'entity_id': 'switch.consommation_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifer_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifer_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifier_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.dehumidifier_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.2myxayqtud9aqbizscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.droger_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.droger_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.droger_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'droger Socket 1', + }), + 'context': , + 'entity_id': 'switch.droger_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.duan_lu_qi_ha_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '断路器HA Child lock', + }), + 'context': , + 'entity_id': 'switch.duan_lu_qi_ha_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.duan_lu_qi_ha_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.duan_lu_qi_ha_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '断路器HA Switch', + }), + 'context': , + 'entity_id': 'switch.duan_lu_qi_ha_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eau_chaude_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eau Chaude Child lock', + }), + 'context': , + 'entity_id': 'switch.eau_chaude_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.eau_chaude_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eau Chaude Switch', + }), + 'context': , + 'entity_id': 'switch.eau_chaude_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.edesanya_energy_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.edesanya_energy_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.edesanya_energy_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Edesanya Energy Switch', + }), + 'context': , + 'entity_id': 'switch.edesanya_energy_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.elivco_kitchen_socket_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco Kitchen Socket Child lock', + }), + 'context': , + 'entity_id': 'switch.elivco_kitchen_socket_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.elivco_kitchen_socket_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Elivco Kitchen Socket Socket 1', + }), + 'context': , + 'entity_id': 'switch.elivco_kitchen_socket_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.elivco_tv_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco TV Child lock', + }), + 'context': , + 'entity_id': 'switch.elivco_tv_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.elivco_tv_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Elivco TV Socket 1', + }), + 'context': , + 'entity_id': 'switch.elivco_tv_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.fakkel_veranda_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fakkel_veranda_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.zspxfhsvgn2hgtndzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.fakkel_veranda_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'fakkel veranda Socket 1', + }), + 'context': , + 'entity_id': 'switch.fakkel_veranda_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.framboisier_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.51tdkcsamisw9ukycpchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisier Child lock', + }), + 'context': , + 'entity_id': 'switch.framboisier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.framboisier_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.51tdkcsamisw9ukycpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisier Socket 1', + }), + 'context': , + 'entity_id': 'switch.framboisier_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.framboisier_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.51tdkcsamisw9ukycpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisier Socket 2', + }), + 'context': , + 'entity_id': 'switch.framboisier_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisiers_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.framboisiers_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisiers_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisiers Switch 1', + }), + 'context': , + 'entity_id': 'switch.framboisiers_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_flip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.53fnjncm3jywuaznpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Flip', + }), + 'context': , + 'entity_id': 'switch.garage_camera_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_motion_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion alarm', + }), + 'context': , + 'entity_id': 'switch.garage_camera_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_motion_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion recording', + }), + 'context': , + 'entity_id': 'switch.garage_camera_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_motion_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion tracking', + }), + 'context': , + 'entity_id': 'switch.garage_camera_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_privacy_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_privacy_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'tuya.53fnjncm3jywuaznpsbasic_private', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Privacy mode', + }), + 'context': , + 'entity_id': 'switch.garage_camera_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_time_watermark-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_time_watermark', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.53fnjncm3jywuaznpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Time watermark', + }), + 'context': , + 'entity_id': 'switch.garage_camera_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_video_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_video_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.53fnjncm3jywuaznpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Video recording', + }), + 'context': , + 'entity_id': 'switch.garage_camera_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_socket_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.garage_socket_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.3d4yosotwk27nqxvzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_socket_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Garage Socket Socket 1', + }), + 'context': , + 'entity_id': 'switch.garage_socket_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garaz_cerpadlo_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.garaz_cerpadlo_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.wc6mumew8inrivi9zcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garaz_cerpadlo_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Garáž čerpadlo Socket', + }), + 'context': , + 'entity_id': 'switch.garaz_cerpadlo_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.hl400_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjklock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Child lock', + }), + 'context': , + 'entity_id': 'switch.hl400_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.hl400_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Ionizer', + }), + 'context': , + 'entity_id': 'switch.hl400_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hl400_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Power', + }), + 'context': , + 'entity_id': 'switch.hl400_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_uv_sterilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.hl400_uv_sterilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 UV sterilization', + }), + 'context': , + 'entity_id': 'switch.hl400_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.home_gateway_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.home_gateway_mute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mute', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mute', + 'unique_id': 'tuya.sdq2flqkq0lblcah2gwmuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.home_gateway_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Gateway Mute', + }), + 'context': , + 'entity_id': 'switch.home_gateway_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hot_water_heat_pump_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hot_water_heat_pump_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.ol8xwtcj42eg18bdbrnzswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hot_water_heat_pump_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hot Water Heat Pump Switch', + }), + 'context': , + 'entity_id': 'switch.hot_water_heat_pump_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hvac_meter_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'HVAC Meter Socket 1', + }), + 'context': , + 'entity_id': 'switch.hvac_meter_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hvac_meter_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'HVAC Meter Socket 2', + }), + 'context': , + 'entity_id': 'switch.hvac_meter_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ineox_sp2_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.vx2owjsg86g2ys93zcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ineox SP2 Child lock', + }), + 'context': , + 'entity_id': 'switch.ineox_sp2_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ineox_sp2_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.vx2owjsg86g2ys93zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Ineox SP2 Socket 1', + }), + 'context': , + 'entity_id': 'switch.ineox_sp2_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ion1000pro_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.owozxdzgbibizu4sjklock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Child lock', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_filter_cartridge_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ion1000pro_filter_cartridge_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cartridge reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cartridge_reset', + 'unique_id': 'tuya.owozxdzgbibizu4sjkfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_filter_cartridge_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Filter cartridge reset', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_filter_cartridge_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ion1000pro_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.owozxdzgbibizu4sjkanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Ionizer', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ion1000pro_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.owozxdzgbibizu4sjkswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Power', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_uv_sterilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ion1000pro_uv_sterilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.owozxdzgbibizu4sjkuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO UV sterilization', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.jardin_fraises_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.jardin_fraises_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.jardin_fraises_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'jardin Fraises Switch 1', + }), + 'context': , + 'entity_id': 'switch.jardin_fraises_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.kabinet_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.kabinet_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.dn7cjik6kwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kabinet_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Child lock', + }), + 'context': , + 'entity_id': 'switch.kabinet_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.kabinet_frost_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.kabinet_frost_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Frost protection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'tuya.dn7cjik6kwfrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kabinet_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Frost protection', + }), + 'context': , + 'entity_id': 'switch.kabinet_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_filter_cartridge_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.kalado_air_purifier_filter_cartridge_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cartridge reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cartridge_reset', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_filter_cartridge_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Filter cartridge reset', + }), + 'context': , + 'entity_id': 'switch.kalado_air_purifier_filter_cartridge_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.kalado_air_purifier_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Power', + }), + 'context': , + 'entity_id': 'switch.kalado_air_purifier_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.keller_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Keller Socket 1', + }), + 'context': , + 'entity_id': 'switch.keller_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.keller_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Keller Socket 2', + }), + 'context': , + 'entity_id': 'switch.keller_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.keller_socket_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Keller Socket 3', + }), + 'context': , + 'entity_id': 'switch.keller_socket_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_usb_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.keller_usb_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'USB 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_usb', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_usb1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_usb_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Keller USB 1', + }), + 'context': , + 'entity_id': 'switch.keller_usb_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lave_linge_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.g0edqq0wzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lave linge Child lock', + }), + 'context': , + 'entity_id': 'switch.lave_linge_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.lave_linge_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g0edqq0wzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Lave linge Socket 1', + }), + 'context': , + 'entity_id': 'switch.lave_linge_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.licht_drucker_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.licht_drucker_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.licht_drucker_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Licht drucker Socket 1', + }), + 'context': , + 'entity_id': 'switch.licht_drucker_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reverse', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol_back', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Reverse', + }), + 'context': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_arm_beep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.multifunction_alarm_arm_beep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Arm beep', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_beep', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_arm_beep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Arm beep', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_arm_beep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.multifunction_alarm_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Siren', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.office_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.2x473nefusdo7af6zcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Child lock', + }), + 'context': , + 'entity_id': 'switch.office_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_lights_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.office_lights_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.O8QpxJwdme33sqn4gkswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_lights_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'office lights Switch 1', + }), + 'context': , + 'entity_id': 'switch.office_lights_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.office_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.2x473nefusdo7af6zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Office Socket 1', + }), + 'context': , + 'entity_id': 'switch.office_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.p1_energia_elettrica_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.p1_energia_elettrica_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.p1_energia_elettrica_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'P1 Energia Elettrica Switch', + }), + 'context': , + 'entity_id': 'switch.p1_energia_elettrica_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.pid_relay_2_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'pid_relay_2 Switch 1', + }), + 'context': , + 'entity_id': 'switch.pid_relay_2_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.pid_relay_2_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'pid_relay_2 Switch 2', + }), + 'context': , + 'entity_id': 'switch.pid_relay_2_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_filter_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_filter_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_filter_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Filter reset', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_filter_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.pixi_smart_drinking_fountain_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Power', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_reset_of_water_usage_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset of water usage days', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_of_water_usage_days', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Reset of water usage days', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_reset_of_water_usage_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_uv_sterilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_uv_sterilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain UV sterilization', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_water_pump_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_water_pump_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water pump reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pump_reset', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_water_pump_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water pump reset', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_water_pump_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.powerplug_5_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.powerplug_5_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.aje5kxgmhhxdihqizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.powerplug_5_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Powerplug 5 Socket 1', + }), + 'context': , + 'entity_id': 'switch.powerplug_5_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pro_breeze_30l_compressor_dehumidifier_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.q8dncqpgin4yympiscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pro Breeze 30L Compressor Dehumidifier Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.pro_breeze_30l_compressor_dehumidifier_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.qt_switch_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.qt_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.a4zeazrz1ata9mbggkswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.qt_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'QT-Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.qt_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.raspy4_home_assistant_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.zaszonjgzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Raspy4 - Home Assistant Child lock', + }), + 'context': , + 'entity_id': 'switch.raspy4_home_assistant_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.raspy4_home_assistant_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.zaszonjgzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Raspy4 - Home Assistant Socket 1', + }), + 'context': , + 'entity_id': 'switch.raspy4_home_assistant_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.rewireable_plug_6930ha_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rewireable_plug_6930ha_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.LS6FfVBVU1vzBRBHzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.rewireable_plug_6930ha_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Rewireable Plug 6930HA Socket 1', + }), + 'context': , + 'entity_id': 'switch.rewireable_plug_6930ha_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sapphire_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sapphire_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.hfqeljop3aihnm73zcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sapphire_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Sapphire Socket', + }), + 'context': , + 'entity_id': 'switch.sapphire_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.schuur_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.schuur_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.NVjuXIQ6QH9eZLHCzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.schuur_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'schuur Socket 1', + }), + 'context': , + 'entity_id': 'switch.schuur_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seating side 6-ch Smart Switch Child lock', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 2', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 3', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 4', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 5', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 6', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_light_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.security_light_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_light_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Light Child lock', + }), + 'context': , + 'entity_id': 'switch.security_light_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_light_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.security_light_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_light_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Security Light Socket 1', + }), + 'context': , + 'entity_id': 'switch.security_light_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_odor_eliminator_pro_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.rl39uwgaqwjwcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_odor_eliminator_pro_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Switch', + }), + 'context': , + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_thermostats_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.smart_thermostats_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.sb3zdertrw50bgogkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_thermostats_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Child lock', + }), + 'context': , + 'entity_id': 'switch.smart_thermostats_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_thermostats_frost_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.smart_thermostats_frost_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Frost protection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'tuya.sb3zdertrw50bgogkwfrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_thermostats_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Frost protection', + }), + 'context': , + 'entity_id': 'switch.smart_thermostats_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smoke_alarm_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.smoke_alarm_mute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mute', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mute', + 'unique_id': 'tuya.p8xoxccrjbwymuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smoke_alarm_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Alarm Mute', + }), + 'context': , + 'entity_id': 'switch.smoke_alarm_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smoke_detector_upstairs_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.smoke_detector_upstairs_mute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mute', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mute', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwymuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smoke_detector_upstairs_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': ' Smoke detector upstairs Mute', + }), + 'context': , + 'entity_id': 'switch.smoke_detector_upstairs_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket3_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.socket3_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket3_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Socket3 Switch 1', + }), + 'context': , + 'entity_id': 'switch.socket3_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket4_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.socket4_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket4_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket4 Child lock', + }), + 'context': , + 'entity_id': 'switch.socket4_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket4_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.socket4_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket4_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Socket4 Socket 1', + }), + 'context': , + 'entity_id': 'switch.socket4_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.solar_zijpad_energy_saving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.solar_zijpad_energy_saving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy saving', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saving', + 'unique_id': 'tuya.couukaypjdnytswitch_save_energy', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.solar_zijpad_energy_saving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Solar zijpad Energy saving', + }), + 'context': , + 'entity_id': 'switch.solar_zijpad_energy_saving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sous_vide_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.sous_vide_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmstart', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sous_vide_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Start', + }), + 'context': , + 'entity_id': 'switch.sous_vide_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.spa_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.spa_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.gi69tunb0esxcnefzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spa_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Spa Socket 1', + }), + 'context': , + 'entity_id': 'switch.spa_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_1_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.spot_1_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.kffnst1epj6vr8xnzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_1_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spot 1 Child lock', + }), + 'context': , + 'entity_id': 'switch.spot_1_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_1_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.spot_1_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.kffnst1epj6vr8xnzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_1_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Spot 1 Socket 1', + }), + 'context': , + 'entity_id': 'switch.spot_1_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_4_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.spot_4_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.LJ9zTFQTfMgsG2Ahzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_4_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Spot 4 Socket 1', + }), + 'context': , + 'entity_id': 'switch.spot_4_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sprinkler_cesare_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sprinkler_cesare_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.tskafaotnfigad6oqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sprinkler_cesare_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sprinkler Cesare Switch', + }), + 'context': , + 'entity_id': 'switch.sprinkler_cesare_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.steckdose_2_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.steckdose_2_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.HzsAAAKFLPABVi8nzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.steckdose_2_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Steckdose 2 Socket', + }), + 'context': , + 'entity_id': 'switch.steckdose_2_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sunbeam_bedding_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power', + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Power', + 'icon': 'mdi:power', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_preheat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sunbeam_bedding_preheat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Preheat', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdpreheat', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_preheat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Preheat', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_preheat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sunbeam_bedding_side_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:alpha-a', + 'original_name': 'Side A Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side A Power', + 'icon': 'mdi:alpha-a', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_preheat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sunbeam_bedding_side_a_preheat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Side A Preheat', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdpreheat_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_preheat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side A Preheat', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_a_preheat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sunbeam_bedding_side_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:alpha-b', + 'original_name': 'Side B Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side B Power', + 'icon': 'mdi:alpha-b', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_preheat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.sunbeam_bedding_side_b_preheat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Side B Preheat', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdpreheat_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_preheat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side B Preheat', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_b_preheat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.term_prizemi_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.term_prizemi_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.jm2fsqtzuhqtbo5ykwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.term_prizemi_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Term - Prizemi Child lock', + }), + 'context': , + 'entity_id': 'switch.term_prizemi_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.terras_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.terras_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.terras_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Terras Socket 1', + }), + 'context': , + 'entity_id': 'switch.terras_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.terras_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.terras_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.terras_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Terras Socket 2', + }), + 'context': , + 'entity_id': 'switch.terras_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.tower_fan_ca_407g_smart_ionizer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.lflvu8cazha8af9jskanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.tower_fan_ca_407g_smart_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart Ionizer', + }), + 'context': , + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.v20_do_not_disturb-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.v20_do_not_disturb', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb', + 'unique_id': 'tuya.zrrraytdoanz33rldsswitch_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.v20_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Do not disturb', + }), + 'context': , + 'entity_id': 'switch.v20_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.valve_controller_2_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.valve_controller_2_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.kx8dncf1qzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.valve_controller_2_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve Controller 2 Switch', + }), + 'context': , + 'entity_id': 'switch.valve_controller_2_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.varmelampa_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.varmelampa_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.sw1ejdomlmfubapizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.varmelampa_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Värmelampa Socket 1', + }), + 'context': , + 'entity_id': 'switch.varmelampa_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wallwasher_front_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Child lock', + }), + 'context': , + 'entity_id': 'switch.wallwasher_front_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.wallwasher_front_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'wallwasher front Socket 1', + }), + 'context': , + 'entity_id': 'switch.wallwasher_front_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_filter_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.water_fountain_filter_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': 'tuya.q304vac40br8nlkajsywcfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_filter_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Filter reset', + }), + 'context': , + 'entity_id': 'switch.water_fountain_filter_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.water_fountain_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.q304vac40br8nlkajsywcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Power', + }), + 'context': , + 'entity_id': 'switch.water_fountain_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_water_pump_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.water_fountain_water_pump_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water pump reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pump_reset', + 'unique_id': 'tuya.q304vac40br8nlkajsywcpump_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_water_pump_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Water pump reset', + }), + 'context': , + 'entity_id': 'switch.water_fountain_water_pump_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.weihnachtsmann_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.rwp6kdezm97s2nktzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachtsmann Child lock', + }), + 'context': , + 'entity_id': 'switch.weihnachtsmann_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.weihnachtsmann_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.rwp6kdezm97s2nktzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Weihnachtsmann Socket 1', + }), + 'context': , + 'entity_id': 'switch.weihnachtsmann_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Child lock', + }), + 'context': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_frost_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_frost_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Frost protection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwfrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Frost protection', + }), + 'context': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smoke_alarm_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wifi_smoke_alarm_mute', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mute', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mute', + 'unique_id': 'tuya.tvgoe1s3fabebcskjbwymuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smoke_alarm_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WIFI Smoke alarm Mute', + }), + 'context': , + 'entity_id': 'switch.wifi_smoke_alarm_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.xoca_dac212xc_v2_s1_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.xoca_dac212xc_v2_s1_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.xoca_dac212xc_v2_s1_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'XOCA-DAC212XC V2-S1 Switch', + }), + 'context': , + 'entity_id': 'switch.xoca_dac212xc_v2_s1_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Child lock', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Switch', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..301a9ea8261 --- /dev/null +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -0,0 +1,111 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[vacuum.hoover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.hoover', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mwsaod7fa3gjyh6ids', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[vacuum.hoover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hoover', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.hoover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[vacuum.v20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.v20', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.zrrraytdoanz33rlds', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[vacuum.v20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_speed': 'strong', + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + 'friendly_name': 'V20', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.v20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_valve.ambr b/tests/components/tuya/snapshots/test_valve.ambr new file mode 100644 index 00000000000..cb5f78a5610 --- /dev/null +++ b/tests/components/tuya/snapshots/test_valve.ambr @@ -0,0 +1,401 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 3', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 4', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 5', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 6', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 7', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_7', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 7', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.jie_hashui_fa_valve_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve 8', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'indexed_valve', + 'unique_id': 'tuya.cijerqyssiwrf7deqzkfsswitch_8', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.jie_hashui_fa_valve_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': '接HA水阀 Valve 8', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.jie_hashui_fa_valve_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tuya/test_alarm_control_panel.py b/tests/components/tuya/test_alarm_control_panel.py new file mode 100644 index 00000000000..53721b1add0 --- /dev/null +++ b/tests/components/tuya/test_alarm_control_panel.py @@ -0,0 +1,32 @@ +"""Test Tuya Alarm Control Panel platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py new file mode 100644 index 00000000000..4da79effde7 --- /dev/null +++ b/tests/components/tuya/test_binary_sensor.py @@ -0,0 +1,76 @@ +"""Test Tuya binary sensor platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockDeviceListener, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_zibqa9dutqyaxym2"], +) +@pytest.mark.parametrize( + ("fault_value", "tankfull", "defrost", "wet"), + [ + (0, "off", "off", "off"), + (0x1, "on", "off", "off"), + (0x2, "off", "on", "off"), + (0x80, "off", "off", "on"), + (0x83, "on", "on", "on"), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_bitmap( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + mock_listener: MockDeviceListener, + fault_value: int, + tankfull: str, + defrost: str, + wet: str, +) -> None: + """Test BITMAP fault sensor on cs_zibqa9dutqyaxym2.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == "off" + assert hass.states.get("binary_sensor.dehumidifier_defrost").state == "off" + assert hass.states.get("binary_sensor.dehumidifier_wet").state == "off" + + await mock_listener.async_send_device_update( + hass, mock_device, {"fault": fault_value} + ) + + assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == tankfull + assert hass.states.get("binary_sensor.dehumidifier_defrost").state == defrost + assert hass.states.get("binary_sensor.dehumidifier_wet").state == wet diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py new file mode 100644 index 00000000000..e9a7b43e103 --- /dev/null +++ b/tests/components/tuya/test_button.py @@ -0,0 +1,32 @@ +"""Test Tuya button platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py new file mode 100644 index 00000000000..94295fe1191 --- /dev/null +++ b/tests/components/tuya/test_camera.py @@ -0,0 +1,49 @@ +"""Test Tuya camera platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock camera access token which normally is randomized.""" + with patch( + "homeassistant.components.camera.SystemRandom.getrandbits", + return_value=1, + ): + yield + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py new file mode 100644 index 00000000000..a0da9359ea3 --- /dev/null +++ b/tests/components/tuya/test_climate.py @@ -0,0 +1,166 @@ +"""Test Tuya climate platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotSupported +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_5wnlzekkstwcdsvm"], +) +async def test_set_temperature( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set temperature service.""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22.7, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "temp_set", "value": 22}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_5wnlzekkstwcdsvm"], +) +async def test_fan_mode_windspeed( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes[ATTR_FAN_MODE] == 1 + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_FAN_MODE: 2, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "windspeed", "value": "2"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_5wnlzekkstwcdsvm"], +) +async def test_fan_mode_no_valid_code( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with no valid code.""" + # Remove windspeed DPCode to simulate a device with no valid fan mode + mock_device.function.pop("windspeed", None) + mock_device.status_range.pop("windspeed", None) + mock_device.status.pop("windspeed", None) + + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes.get(ATTR_FAN_MODE) is None + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_FAN_MODE: 2, + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_5wnlzekkstwcdsvm"], +) +async def test_set_humidity_not_supported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service (not available on this device).""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HUMIDITY: 50, + }, + blocking=True, + ) diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py new file mode 100644 index 00000000000..7206aaf1cff --- /dev/null +++ b/tests/components/tuya/test_cover.py @@ -0,0 +1,207 @@ +"""Test Tuya cover platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, +) +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotSupported +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_zah67ekd"], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_open_service( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test open service.""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "control", "value": "open"}, + {"code": "percent_control", "value": 0}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_zah67ekd"], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_close_service( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test close service.""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "control", "value": "close"}, + {"code": "percent_control", "value": 100}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_zah67ekd"], +) +async def test_set_position( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set position service (not available on this device).""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_POSITION: 25, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "percent_control", "value": 75}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_zah67ekd"], +) +@pytest.mark.parametrize( + ("percent_control", "percent_state"), + [ + (100, 52), + (0, 100), + (50, 25), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_percent_state_on_cover( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + percent_control: int, + percent_state: int, +) -> None: + """Test percent_state attribute on the cover entity.""" + mock_device.status["percent_control"] = percent_control + # 100 is closed and 0 is open for Tuya covers + mock_device.status["percent_state"] = 100 - percent_state + + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes[ATTR_CURRENT_POSITION] == percent_state + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_zah67ekd"], +) +async def test_set_tilt_position_not_supported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set tilt position service (not available on this device).""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TILT_POSITION: 50, + }, + blocking=True, + ) diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py new file mode 100644 index 00000000000..f07c2faa229 --- /dev/null +++ b/tests/components/tuya/test_diagnostics.py @@ -0,0 +1,67 @@ +"""Test Tuya diagnostics platform.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import initialize_entry + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("mock_device_code", ["rqbj_4iqe2hsfyd86kwwc"]) +async def test_entry_diagnostics( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) + + +@pytest.mark.parametrize("mock_device_code", ["rqbj_4iqe2hsfyd86kwwc"]) +async def test_device_diagnostics( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + device = device_registry.async_get_device(identifiers={(DOMAIN, mock_device.id)}) + assert device, repr(device_registry.devices) + + result = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + assert result == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) diff --git a/tests/components/tuya/test_event.py b/tests/components/tuya/test_event.py new file mode 100644 index 00000000000..6e493ae41c0 --- /dev/null +++ b/tests/components/tuya/test_event.py @@ -0,0 +1,32 @@ +"""Test Tuya event platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py new file mode 100644 index 00000000000..992c989e352 --- /dev/null +++ b/tests/components/tuya/test_fan.py @@ -0,0 +1,32 @@ +"""Test Tuya fan platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py new file mode 100644 index 00000000000..c38e5521990 --- /dev/null +++ b/tests/components/tuya/test_humidifier.py @@ -0,0 +1,233 @@ +"""Test Tuya humidifier platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_zibqa9dutqyaxym2"], +) +async def test_turn_on( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": True}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_zibqa9dutqyaxym2"], +) +async def test_turn_off( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": False}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_zibqa9dutqyaxym2"], +) +async def test_set_humidity( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HUMIDITY: 50, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "dehumidify_set_value", "value": 50}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_zibqa9dutqyaxym2"], +) +async def test_turn_on_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service (not supported by this device).""" + # Remove switch control - but keep other functionality + mock_device.status.pop("switch") + mock_device.function.pop("switch") + mock_device.status_range.pop("switch") + + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_zibqa9dutqyaxym2"], +) +async def test_turn_off_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service (not supported by this device).""" + # Remove switch control - but keep other functionality + mock_device.status.pop("switch") + mock_device.function.pop("switch") + mock_device.status_range.pop("switch") + + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_zibqa9dutqyaxym2"], +) +async def test_set_humidity_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service (not supported by this device).""" + # Remove set humidity control - but keep other functionality + mock_device.status.pop("dehumidify_set_value") + mock_device.function.pop("dehumidify_set_value") + mock_device.status_range.pop("dehumidify_set_value") + + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HUMIDITY: 50, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['dehumidify_set_value']", + "available": ("['child_lock', 'countdown_set', 'switch']"), + } diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py new file mode 100644 index 00000000000..545a5a7f07c --- /dev/null +++ b/tests/components/tuya/test_init.py @@ -0,0 +1,68 @@ +"""Test Tuya initialization.""" + +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, async_load_json_object_fixture + + +async def test_device_registry( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: CustomerDevice, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Validate device registry snapshots for all devices, including unsupported ones.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + device_registry_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + # Ensure the device registry contains same amount as DEVICE_MOCKS + assert len(device_registry_entries) == len(DEVICE_MOCKS) + + for device_registry_entry in device_registry_entries: + assert device_registry_entry == snapshot( + name=list(device_registry_entry.identifiers)[0][1] + ) + + # Ensure model is suffixed with "(unsupported)" when no entities are generated + assert (" (unsupported)" in device_registry_entry.model) == ( + not er.async_entries_for_device( + entity_registry, + device_registry_entry.id, + include_disabled_entities=True, + ) + ) + + +async def test_fixtures_valid(hass: HomeAssistant) -> None: + """Ensure Tuya fixture files are valid.""" + # We want to ensure that the fixture files do not contain + # `home_assistant`, `id`, or `terminal_id` keys. + # These are provided by the Tuya diagnostics and should be removed + # from the fixture. + EXCLUDE_KEYS = ("home_assistant", "id", "terminal_id") + + for device_code in DEVICE_MOCKS: + details = await async_load_json_object_fixture( + hass, f"{device_code}.json", DOMAIN + ) + for key in EXCLUDE_KEYS: + assert key not in details, ( + f"Please remove data[`'{key}']` from {device_code}.json" + ) diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py new file mode 100644 index 00000000000..e87eb139385 --- /dev/null +++ b/tests/components/tuya/test_light.py @@ -0,0 +1,148 @@ +"""Test Tuya light platform.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_WHITE, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["dj_mki13ie507rlry4r"], +) +@pytest.mark.parametrize( + ("turn_on_input", "expected_commands"), + [ + ( + { + ATTR_WHITE: True, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + {"code": "bright_value_v2", "value": 546}, + ], + ), + ( + { + ATTR_BRIGHTNESS: 150, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "bright_value_v2", "value": 592}, + ], + ), + ( + { + ATTR_WHITE: True, + ATTR_BRIGHTNESS: 150, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + {"code": "bright_value_v2", "value": 592}, + ], + ), + ( + { + ATTR_WHITE: 150, + }, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + {"code": "bright_value_v2", "value": 592}, + ], + ), + ], +) +async def test_turn_on_white( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + turn_on_input: dict[str, Any], + expected_commands: list[dict[str, Any]], +) -> None: + """Test turn_on service.""" + entity_id = "light.garage_light" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + **turn_on_input, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + expected_commands, + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["dj_mki13ie507rlry4r"], +) +async def test_turn_off( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn_off service.""" + entity_id = "light.garage_light" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_led", "value": False}] + ) diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py new file mode 100644 index 00000000000..89124fdf65f --- /dev/null +++ b/tests/components/tuya/test_number.py @@ -0,0 +1,112 @@ +"""Test Tuya number platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_gyitctrjj1kefxp2"], +) +async def test_set_value( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value.""" + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 18, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "delay_set", "value": 18}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_gyitctrjj1kefxp2"], +) +async def test_set_value_no_function( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value when no function available.""" + + # Mock a device with delay_set in status but not in function or status_range + mock_device.function.pop("delay_set") + mock_device.status_range.pop("delay_set") + + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 18, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['delay_set']", + "available": ( + "['alarm_delay_time', 'alarm_time', 'master_mode', 'master_state', " + "'muffling', 'sub_admin', 'sub_class', 'switch_alarm_light', " + "'switch_alarm_propel', 'switch_alarm_sound', 'switch_kb_light', " + "'switch_kb_sound', 'switch_mode_sound']" + ), + } diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py new file mode 100644 index 00000000000..c35963528d4 --- /dev/null +++ b/tests/components/tuya/test_select.py @@ -0,0 +1,98 @@ +"""Test Tuya select platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_zah67ekd"], +) +async def test_select_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "forward", + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "control_back_mode", "value": "forward"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_zah67ekd"], +) +async def test_select_invalid_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "hello", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_option" diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py new file mode 100644 index 00000000000..a5d61ea47a6 --- /dev/null +++ b/tests/components/tuya/test_sensor.py @@ -0,0 +1,34 @@ +"""Test Tuya sensor platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py new file mode 100644 index 00000000000..1043c0a3a0f --- /dev/null +++ b/tests/components/tuya/test_siren.py @@ -0,0 +1,32 @@ +"""Test Tuya siren platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py new file mode 100644 index 00000000000..e763fe3bd91 --- /dev/null +++ b/tests/components/tuya/test_switch.py @@ -0,0 +1,32 @@ +"""Test Tuya switch platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py new file mode 100644 index 00000000000..5ee5b965137 --- /dev/null +++ b/tests/components/tuya/test_vacuum.py @@ -0,0 +1,67 @@ +"""Test Tuya vacuum platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sd_lr33znaodtyarrrz"], +) +async def test_return_home( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test return home service.""" + # Based on #141278 + entity_id = "vacuum.v20" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_charge", "value": True}] + ) diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py new file mode 100644 index 00000000000..73ccfba7fc4 --- /dev/null +++ b/tests/components/tuya/test_valve.py @@ -0,0 +1,96 @@ +"""Test Tuya valve platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_devices: list[CustomerDevice], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sfkzq_ed7frwissyqrejic"], +) +async def test_open_valve( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test opening a valve.""" + entity_id = "valve.jie_hashui_fa_valve_1" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_1", "value": True}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sfkzq_ed7frwissyqrejic"], +) +async def test_close_valve( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test closing a valve.""" + entity_id = "valve.jie_hashui_fa_valve_1" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_1", "value": False}] + ) diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 915c0f5080e..15fcd7cee09 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -97,7 +97,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -107,7 +106,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 9e8bb6f7381..3b4e21be1e1 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -66,7 +66,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -76,7 +75,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -148,7 +146,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -158,7 +155,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -230,7 +226,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -240,7 +235,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -312,7 +306,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -322,7 +315,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -394,7 +386,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -404,7 +395,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index c49ade514bc..895ba62f81a 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -32,6 +32,7 @@ from uiprotect.data import ( from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -68,6 +69,7 @@ def mock_ufp_config_entry(): "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 13e93a8c2e7..dc841ab7a1e 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -5,7 +5,7 @@ "canAutoUpdate": true, "isStatsGatheringEnabled": true, "timezone": "America/New_York", - "version": "2.2.6", + "version": "6.0.0", "ucoreVersion": "2.3.26", "firmwareVersion": "2.3.10", "uiVersion": null, diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3aa441659b0..0c4d6e00066 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -111,8 +111,8 @@ async def test_binary_sensor_setup_light( assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) for description in LIGHT_SENSOR_WRITE: - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, description ) entity = entity_registry.async_get(entity_id) @@ -139,8 +139,8 @@ async def test_binary_sensor_setup_camera_all( assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 6) description = EVENT_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -154,8 +154,8 @@ async def test_binary_sensor_setup_camera_all( # Is Dark description = CAMERA_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -169,8 +169,8 @@ async def test_binary_sensor_setup_camera_all( # Motion description = EVENT_SENSORS[1] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -197,8 +197,8 @@ async def test_binary_sensor_setup_camera_none( description = CAMERA_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, camera, description ) entity = entity_registry.async_get(entity_id) @@ -229,8 +229,8 @@ async def test_binary_sensor_setup_sensor( STATE_OFF, ] for index, description in enumerate(SENSE_SENSORS_WRITE): - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -262,8 +262,8 @@ async def test_binary_sensor_setup_sensor_leak( STATE_OFF, ] for index, description in enumerate(SENSE_SENSORS_WRITE): - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -288,8 +288,8 @@ async def test_binary_sensor_update_motion( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 12) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] ) event = Event( @@ -334,8 +334,8 @@ async def test_binary_sensor_update_light_motion( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) event_metadata = EventMetadata(light_id=light.id) @@ -378,8 +378,8 @@ async def test_binary_sensor_update_mount_type_window( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -410,8 +410,8 @@ async def test_binary_sensor_update_mount_type_garage( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -451,8 +451,8 @@ async def test_binary_sensor_package_detected( doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PACKAGE) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[6] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[6] ) event = Event( @@ -592,8 +592,8 @@ async def test_binary_sensor_person_detected( doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] ) events = async_capture_events(hass, EVENT_STATE_CHANGED) diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 34a1d064547..9c78e09d264 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -396,10 +396,10 @@ async def test_camera_image( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - ufp.api.get_camera_snapshot = AsyncMock() + ufp.api.get_public_api_camera_snapshot = AsyncMock() await async_get_image(hass, "camera.test_camera_high_resolution_channel") - ufp.api.get_camera_snapshot.assert_called_once() + ufp.api.get_public_api_camera_snapshot.assert_called_once() async def test_package_camera_image( diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 0eae2a48fea..a5cda887b4d 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import asdict import socket -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect import NotAuthorized, NvrError, ProtectApiClient @@ -74,6 +74,10 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -89,6 +93,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -99,6 +104,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -116,9 +122,15 @@ async def test_form_version_too_old( ) bootstrap.nvr = old_nvr - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -126,6 +138,7 @@ async def test_form_version_too_old( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -133,15 +146,21 @@ async def test_form_version_too_old( assert result2["errors"] == {"base": "protect_version"} -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +async def test_form_invalid_auth_password(hass: HomeAssistant) -> None: + """Test we handle invalid auth password.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -149,6 +168,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -156,6 +176,38 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: assert result2["errors"] == {"password": "invalid_auth"} +async def test_form_invalid_auth_api_key( + hass: HomeAssistant, bootstrap: Bootstrap +) -> None: + """Test we handle invalid auth api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NotAuthorized, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"api_key": "invalid_auth"} + + async def test_form_cloud_user( hass: HomeAssistant, bootstrap: Bootstrap, cloud_account: CloudAccount ) -> None: @@ -167,9 +219,15 @@ async def test_form_cloud_user( user = bootstrap.users[bootstrap.auth_user_id] user.cloud_account = cloud_account bootstrap.users[bootstrap.auth_user_id] = user - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -177,6 +235,7 @@ async def test_form_cloud_user( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -190,9 +249,15 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NvrError, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NvrError, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NvrError, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -200,6 +265,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -217,6 +283,7 @@ async def test_form_reauth_auth( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -234,15 +301,22 @@ async def test_form_reauth_auth( "name": "Mock Title", } - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -260,12 +334,17 @@ async def test_form_reauth_auth( "homeassistant.components.unifiprotect.async_setup", return_value=True, ) as mock_setup, + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { "username": "test-username", "password": "new-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -283,6 +362,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -325,7 +405,6 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - "disable_rtsp": True, "override_connection_host": True, "max_media": 1000, - "allow_ea_channel": False, } await hass.async_block_till_done() await hass.config_entries.async_unload(mock_config.entry_id) @@ -384,6 +463,10 @@ async def test_discovered_by_unifi_discovery_direct_connect( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -398,6 +481,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -408,6 +492,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -426,6 +511,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -584,6 +670,10 @@ async def test_discovered_by_unifi_discovery( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", side_effect=[NotAuthorized, bootstrap], ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -598,6 +688,7 @@ async def test_discovered_by_unifi_discovery( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -608,6 +699,7 @@ async def test_discovered_by_unifi_discovery( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -645,6 +737,10 @@ async def test_discovered_by_unifi_discovery_partial( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -659,6 +755,7 @@ async def test_discovered_by_unifi_discovery_partial( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -669,6 +766,7 @@ async def test_discovered_by_unifi_discovery_partial( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -687,6 +785,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -717,6 +816,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "127.0.0.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -747,6 +847,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -788,12 +889,14 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, }, unique_id="FFFFFFAAAAAA", ) + mock_config.runtime_data = Mock(async_stop=AsyncMock()) mock_config.add_to_hass(hass) other_ip_dict = UNIFI_DISCOVERY_DICT.copy() @@ -827,6 +930,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -841,6 +948,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -851,11 +959,12 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "nomatchsameip.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, } - assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 2 assert len(mock_setup.mock_calls) == 1 @@ -869,6 +978,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index fd882929e96..b478d7bbd2c 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -2,7 +2,6 @@ from uiprotect.data import NVR, Light -from homeassistant.components.unifiprotect.const import CONF_ALLOW_EA from homeassistant.core import HomeAssistant from .utils import MockUFPFixture, init_entry @@ -22,7 +21,6 @@ async def test_diagnostics( await init_entry(hass, ufp, [light]) options = dict(ufp.entry.options) - options[CONF_ALLOW_EA] = True hass.config_entries.async_update_entry(ufp.entry, options=options) await hass.async_block_till_done() @@ -30,7 +28,6 @@ async def test_diagnostics( assert "options" in diag and isinstance(diag["options"], dict) options = diag["options"] - assert options[CONF_ALLOW_EA] is True assert "bootstrap" in diag and isinstance(diag["bootstrap"], dict) bootstrap = diag["bootstrap"] diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index 032a3b253a7..80b11c047cc 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -57,8 +57,8 @@ async def test_doorbell_ring( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] ) unsub = async_track_state_change_event(hass, entity_id, _capture_event) @@ -171,8 +171,8 @@ async def test_doorbell_nfc_scanned( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -246,8 +246,8 @@ async def test_doorbell_nfc_scanned_ulpusr_deactivated( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -322,8 +322,8 @@ async def test_doorbell_nfc_scanned_no_ulpusr( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -390,8 +390,8 @@ async def test_doorbell_nfc_scanned_no_keyring( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) test_nfc_id = "test_nfc_id" @@ -451,8 +451,8 @@ async def test_doorbell_fingerprint_identified( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -519,8 +519,8 @@ async def test_doorbell_fingerprint_identified_user_deactivated( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -588,8 +588,8 @@ async def test_doorbell_fingerprint_identified_no_user( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -649,8 +649,8 @@ async def test_doorbell_fingerprint_not_identified( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) unsub = async_track_state_change_event(hass, entity_id, _capture_event) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index b01c7e0cf4a..0776feece54 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -5,19 +5,21 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, patch import pytest -from uiprotect import NotAuthorized, NvrError, ProtectApiClient +from uiprotect import NvrError, ProtectApiClient from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import NVR, Bootstrap, CloudAccount, Light +from uiprotect.exceptions import BadRequest, NotAuthorized from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, - CONF_DISABLE_RTSP, + CONF_ALLOW_EA, DOMAIN, ) from homeassistant.components.unifiprotect.data import ( async_ufp_instance_for_config_entry_ids, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -29,6 +31,19 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +@pytest.fixture +def mock_user_can_write_nvr(request: pytest.FixtureRequest, ufp: MockUFPFixture): + """Fixture to mock can_write method on NVR objects with indirect parametrization.""" + can_write_result = getattr(request, "param", True) + original_can_write = ufp.api.bootstrap.nvr.can_write + mock_can_write = Mock(return_value=can_write_result) + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", mock_can_write) + try: + yield mock_can_write + finally: + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", original_can_write) + + async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture) -> None: """Test working setup of unifiprotect entry.""" @@ -68,6 +83,7 @@ async def test_setup_multiple( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -86,22 +102,6 @@ async def test_setup_multiple( assert mock_config.unique_id == ufp.api.bootstrap.nvr.mac -async def test_reload(hass: HomeAssistant, ufp: MockUFPFixture) -> None: - """Test updating entry reload entry.""" - - await hass.config_entries.async_setup(ufp.entry.entry_id) - await hass.async_block_till_done() - assert ufp.entry.state is ConfigEntryState.LOADED - - options = dict(ufp.entry.options) - options[CONF_DISABLE_RTSP] = True - hass.config_entries.async_update_entry(ufp.entry, options=options) - await hass.async_block_till_done() - - assert ufp.entry.state is ConfigEntryState.LOADED - assert ufp.api.async_disconnect_ws.called - - async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture, light: Light) -> None: """Test unloading of unifiprotect entry.""" @@ -345,3 +345,196 @@ async def test_async_ufp_instance_for_config_entry_ids( result = async_ufp_instance_for_config_entry_ids(hass, entry_ids) assert result == expected_result + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_creates_api_key_when_missing( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key is created when missing and user has write permissions.""" + # Setup: API key is not set initially, user has write permissions + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") + + # Mock set_api_key to update is_api_key_set return value when called + def set_api_key_side_effect(key): + ufp.api.is_api_key_set.return_value = True + + ufp.api.set_api_key.side_effect = set_api_key_side_effect + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify API key was created and set + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") + + # Verify config entry was updated with new API key + assert ufp.entry.data[CONF_API_KEY] == "new-api-key-123" + assert ufp.entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [False], indirect=True) +async def test_setup_skips_api_key_creation_when_no_write_permission( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key creation is skipped when user has no write permissions.""" + # Setup: API key is not set, user has no write permissions + ufp.api.is_api_key_set.return_value = False + + # Should fail with auth error since no API key and can't create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_handles_api_key_creation_failure( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling of API key creation failure.""" + # Setup: API key is not set, user has write permissions, but creation fails + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock( + side_effect=NotAuthorized("Failed to create API key") + ) + + # Should fail with auth error due to API key creation failure + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted but set_api_key was not called + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_handles_api_key_creation_bad_request( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling of API key creation BadRequest error.""" + # Setup: API key is not set, user has write permissions, but creation fails with BadRequest + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock( + side_effect=BadRequest("Invalid API key creation request") + ) + + # Should fail with auth error due to API key creation failure + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted but set_api_key was not called + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_not_called() + + +async def test_setup_with_existing_api_key( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test setup when API key is already set.""" + # Setup: API key is already set + ufp.api.is_api_key_set.return_value = True + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.LOADED + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_api_key_creation_returns_none( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling when API key creation returns None.""" + # Setup: API key is not set, creation returns None (empty response) + # set_api_key will be called with None but is_api_key_set will still be False + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value=None) + + # Should fail with auth error since API key creation returned None + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted and set_api_key was called with None + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with(None) + + +async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: + """Test remove CONF_ALLOW_EA from options while migrating a 1 config entry to 2.""" + with ( + patch( + "homeassistant.components.unifiprotect.async_setup_entry", return_value=True + ), + patch("homeassistant.components.unifiprotect.async_start_discovery"), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={"test": "1", "test2": "2", CONF_ALLOW_EA: "True"}, + version=1, + unique_id="123456", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 2 + assert entry.options.get(CONF_ALLOW_EA) is None + assert entry.unique_id == "123456" + + +async def test_setup_skips_api_key_creation_when_no_auth_user( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test that API key creation is skipped when auth_user is None.""" + # Setup: API key is not set, auth_user is None + ufp.api.is_api_key_set.return_value = False + + # Mock the users dictionary to return None for any user ID + with patch.dict(ufp.api.bootstrap.users, {}, clear=True): + # Should fail with auth error since no API key and no auth user to create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_fails_when_api_key_still_missing_after_creation( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that setup fails when API key is still missing after creation attempts.""" + # Setup: API key is not set and remains not set even after attempts + ufp.api.is_api_key_set.return_value = False # type: ignore[attr-defined] + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") # type: ignore[method-assign] + ufp.api.set_api_key = Mock() # type: ignore[method-assign] # Mock this but API key still won't be "set" + + # Setup should fail since API key is still not set after creation + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify entry is in setup error state (which will trigger reauth automatically) + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted + ufp.api.create_api_key.assert_called_once_with( # type: ignore[attr-defined] + name="Home Assistant (test home)" + ) + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") # type: ignore[attr-defined] diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 61f9680bdbc..02d07bb1d4d 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -234,6 +234,7 @@ async def test_browse_media_root_multiple_consoles( "host": "1.1.1.2", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect2", "port": 443, "verify_ssl": False, diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 1838a574bc4..a93c49a2ebe 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -80,8 +80,8 @@ async def test_number_setup_light( assert_entity_counts(hass, Platform.NUMBER, 2, 2) for description in LIGHT_NUMBERS: - unique_id, entity_id = ids_from_device_description( - Platform.NUMBER, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description ) entity = entity_registry.async_get(entity_id) @@ -111,8 +111,8 @@ async def test_number_setup_camera_all( assert_entity_counts(hass, Platform.NUMBER, 5, 5) for description in CAMERA_NUMBERS: - unique_id, entity_id = ids_from_device_description( - Platform.NUMBER, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, description ) entity = entity_registry.async_get(entity_id) @@ -165,7 +165,9 @@ async def test_number_light_sensitivity( light.__pydantic_fields__["set_sensitivity"] = Mock(final=False, frozen=False) light.set_sensitivity = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True @@ -187,7 +189,9 @@ async def test_number_light_duration( light.__pydantic_fields__["set_duration"] = Mock(final=False, frozen=False) light.set_duration = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True @@ -215,7 +219,9 @@ async def test_number_camera_simple( ) setattr(camera, description.ufp_set_method, AsyncMock()) - _, entity_id = ids_from_device_description(Platform.NUMBER, camera, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 1.0}, blocking=True @@ -237,7 +243,9 @@ async def test_number_lock_auto_close( ) doorlock.set_auto_close_time = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, doorlock, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, doorlock, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 1f025a63306..c1eef3f7839 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -35,8 +35,8 @@ async def test_exclude_attributes( now = fixed_now await init_entry(hass, ufp, [doorbell, unadopted_camera]) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] ) event = Event( diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index 1117038bbd0..2d08630e520 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -2,8 +2,8 @@ from __future__ import annotations -from copy import copy, deepcopy -from unittest.mock import AsyncMock, Mock +from copy import deepcopy +from unittest.mock import AsyncMock from uiprotect.data import Camera, CloudAccount, ModelType, Version @@ -21,110 +21,6 @@ from tests.components.repairs import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator -async def test_ea_warning_ignore( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test EA warning is created if using prerelease version of Protect.""" - - ufp.api.bootstrap.nvr.release_channel = "beta" - ufp.api.bootstrap.nvr.version = Version("1.21.0-beta.2") - version = ufp.api.bootstrap.nvr.version - assert version.is_prerelease - await init_entry(hass, ufp, []) - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "ea_channel_warning": - issue = i - assert issue is not None - - data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning") - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", - "version": str(version), - } - assert data["step_id"] == "start" - - data = await process_repair_fix_flow(client, flow_id) - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", - "version": str(version), - } - assert data["step_id"] == "confirm" - - data = await process_repair_fix_flow(client, flow_id) - - assert data["type"] == "create_entry" - - -async def test_ea_warning_fix( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test EA warning is created if using prerelease version of Protect.""" - - ufp.api.bootstrap.nvr.release_channel = "beta" - ufp.api.bootstrap.nvr.version = Version("1.21.0-beta.2") - version = ufp.api.bootstrap.nvr.version - assert version.is_prerelease - await init_entry(hass, ufp, []) - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "ea_channel_warning": - issue = i - assert issue is not None - - data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning") - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", - "version": str(version), - } - assert data["step_id"] == "start" - - new_nvr = copy(ufp.api.bootstrap.nvr) - new_nvr.release_channel = "release" - new_nvr.version = Version("2.2.6") - mock_msg = Mock() - mock_msg.changed_data = {"version": "2.2.6", "releaseChannel": "release"} - mock_msg.new_obj = new_nvr - - ufp.api.bootstrap.nvr = new_nvr - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() - - data = await process_repair_fix_flow(client, flow_id) - - assert data["type"] == "create_entry" - - async def test_cloud_user_fix( hass: HomeAssistant, ufp: MockUFPFixture, diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 6db3ae22dcb..f8485e678a1 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -98,8 +98,8 @@ async def test_select_setup_light( expected_values = ("On Motion - When Dark", "Not Paired") for index, description in enumerate(LIGHT_SELECTS): - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, description ) entity = entity_registry.async_get(entity_id) @@ -127,8 +127,8 @@ async def test_select_setup_viewer( description = VIEWER_SELECTS[0] - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, viewer, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, description ) entity = entity_registry.async_get(entity_id) @@ -161,8 +161,8 @@ async def test_select_setup_camera_all( ) for index, description in enumerate(CAMERA_SELECTS): - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -192,8 +192,8 @@ async def test_select_setup_camera_none( if index == 2: return - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, camera, description ) entity = entity_registry.async_get(entity_id) @@ -215,8 +215,8 @@ async def test_select_update_liveview( await init_entry(hass, ufp, [viewer]) assert_entity_counts(hass, Platform.SELECT, 1, 1) - _, entity_id = ids_from_device_description( - Platform.SELECT, viewer, VIEWER_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, VIEWER_SELECTS[0] ) state = hass.states.get(entity_id) @@ -252,8 +252,8 @@ async def test_select_update_doorbell_settings( expected_length = len(ufp.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -296,8 +296,8 @@ async def test_select_update_doorbell_message( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -330,7 +330,9 @@ async def test_select_set_option_light_motion( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) - _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) light.__pydantic_fields__["set_light_settings"] = Mock(final=False, frozen=False) light.set_light_settings = AsyncMock() @@ -355,7 +357,9 @@ async def test_select_set_option_light_camera( await init_entry(hass, ufp, [light, camera]) assert_entity_counts(hass, Platform.SELECT, 4, 4) - _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[1] + ) light.__pydantic_fields__["set_paired_camera"] = Mock(final=False, frozen=False) light.set_paired_camera = AsyncMock() @@ -389,8 +393,8 @@ async def test_select_set_option_camera_recording( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[0] ) doorbell.__pydantic_fields__["set_recording_mode"] = Mock(final=False, frozen=False) @@ -414,8 +418,8 @@ async def test_select_set_option_camera_ir( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) doorbell.__pydantic_fields__["set_ir_led_model"] = Mock(final=False, frozen=False) @@ -439,8 +443,8 @@ async def test_select_set_option_camera_doorbell_custom( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -466,8 +470,8 @@ async def test_select_set_option_camera_doorbell_unifi( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -508,8 +512,8 @@ async def test_select_set_option_camera_doorbell_default( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -537,8 +541,8 @@ async def test_select_set_option_viewer( await init_entry(hass, ufp, [viewer]) assert_entity_counts(hass, Platform.SELECT, 1, 1) - _, entity_id = ids_from_device_description( - Platform.SELECT, viewer, VIEWER_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, VIEWER_SELECTS[0] ) viewer.__pydantic_fields__["set_liveview"] = Mock(final=False, frozen=False) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 9489a49bf22..75193a491c9 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.components.unifiprotect.sensor import ( NVR_DISABLED_SENSORS, NVR_SENSORS, SENSE_SENSORS, + ProtectSensorEntityDescription, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -55,6 +56,16 @@ from .utils import ( from tests.common import async_capture_events + +def get_sensor_by_key(sensors: tuple, key: str) -> ProtectSensorEntityDescription: + """Get sensor description by key.""" + for sensor in sensors: + if sensor.key == key: + return sensor + raise ValueError(f"Sensor with key '{key}' not found") + + +# Constants for test slicing (subsets of sensor tuples) CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] @@ -108,8 +119,8 @@ async def test_sensor_setup_sensor( for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -122,8 +133,11 @@ async def test_sensor_setup_sensor( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION # BLE signal - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, ALL_DEVICES_SENSORS[1] + unique_id, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(ALL_DEVICES_SENSORS, "ble_signal"), ) entity = entity_registry.async_get(entity_id) @@ -160,8 +174,8 @@ async def test_sensor_setup_sensor_none( for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -215,8 +229,8 @@ async def test_sensor_setup_nvr( "50", ) for index, description in enumerate(NVR_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -234,8 +248,8 @@ async def test_sensor_setup_nvr( expected_values = ("50.0", "50.0", "50.0") for index, description in enumerate(NVR_DISABLED_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -269,9 +283,9 @@ async def test_sensor_nvr_missing_values( assert_entity_counts(hass, Platform.SENSOR, 12, 9) # Uptime - description = NVR_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + description = get_sensor_by_key(NVR_SENSORS, "uptime") + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -285,10 +299,10 @@ async def test_sensor_nvr_missing_values( assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Memory - description = NVR_SENSORS[8] - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + # Recording capacity + description = get_sensor_by_key(NVR_SENSORS, "record_capacity") + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -300,10 +314,10 @@ async def test_sensor_nvr_missing_values( assert state.state == "0" assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Memory - description = NVR_DISABLED_SENSORS[2] - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + # Memory utilization + description = get_sensor_by_key(NVR_DISABLED_SENSORS, "memory_utilization") + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -340,8 +354,8 @@ async def test_sensor_setup_camera( for index, description in enumerate(CAMERA_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -356,8 +370,8 @@ async def test_sensor_setup_camera( expected_values = ("0.0001", "0.0001") for index, description in enumerate(CAMERA_DISABLED_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -372,9 +386,12 @@ async def test_sensor_setup_camera( assert state.state == expected_values[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Wired signal - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[2] + # Wired signal (phy_rate / link speed) + unique_id, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + doorbell, + get_sensor_by_key(ALL_DEVICES_SENSORS, "phy_rate"), ) entity = entity_registry.async_get(entity_id) @@ -389,9 +406,12 @@ async def test_sensor_setup_camera( assert state.state == "1000" assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # WiFi signal - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[3] + # Wi-Fi signal + unique_id, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + doorbell, + get_sensor_by_key(ALL_DEVICES_SENSORS, "wifi_signal"), ) entity = entity_registry.async_get(entity_id) @@ -421,8 +441,11 @@ async def test_sensor_setup_camera_with_last_trip_time( assert_entity_counts(hass, Platform.SENSOR, 24, 24) # Last Trip Time - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, MOTION_TRIP_SENSORS[0] + unique_id, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + doorbell, + get_sensor_by_key(MOTION_TRIP_SENSORS, "motion_last_trip_time"), ) entity = entity_registry.async_get(entity_id) @@ -446,8 +469,11 @@ async def test_sensor_update_alarm( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) - _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[4] + _, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "alarm_sound"), ) event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") @@ -497,8 +523,11 @@ async def test_sensor_update_alarm_with_last_trip_time( assert_entity_counts(hass, Platform.SENSOR, 22, 22) # Last Trip Time - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[-3] + unique_id, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "door_last_trip_time"), ) entity = entity_registry.async_get(entity_id) @@ -528,8 +557,11 @@ async def test_camera_update_license_plate( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -643,8 +675,11 @@ async def test_camera_update_license_plate_changes_number_during_detect( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -730,8 +765,11 @@ async def test_camera_update_license_plate_multiple_updates( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -853,8 +891,11 @@ async def test_camera_update_license_no_dupes( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -946,6 +987,8 @@ async def test_sensor_precision( assert_entity_counts(hass, Platform.SENSOR, 22, 14) nvr: NVR = ufp.api.bootstrap.nvr - _, entity_id = ids_from_device_description(Platform.SENSOR, nvr, NVR_SENSORS[6]) + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, get_sensor_by_key(NVR_SENSORS, "resolution_4K") + ) assert hass.states.get(entity_id).state == "17.49" diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 1a899550204..501418948c6 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -135,8 +135,8 @@ async def test_switch_setup_light( description = LIGHT_SWITCHES[1] - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description ) entity = entity_registry.async_get(entity_id) @@ -178,8 +178,8 @@ async def test_switch_setup_camera_all( assert_entity_counts(hass, Platform.SWITCH, 17, 15) for description in CAMERA_SWITCHES_BASIC: - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -224,8 +224,8 @@ async def test_switch_setup_camera_none( if description.ufp_required_field is not None: continue - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, camera, description ) entity = entity_registry.async_get(entity_id) @@ -268,7 +268,9 @@ async def test_switch_light_status( light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False) light.set_status_light = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -296,7 +298,9 @@ async def test_switch_camera_ssh( doorbell.__pydantic_fields__["set_ssh"] = Mock(final=False, frozen=False) doorbell.set_ssh = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await enable_entity(hass, ufp.entry.entry_id, entity_id) await hass.services.async_call( @@ -332,7 +336,9 @@ async def test_switch_camera_simple( setattr(doorbell, description.ufp_set_method, AsyncMock()) set_method = getattr(doorbell, description.ufp_set_method) - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -360,7 +366,9 @@ async def test_switch_camera_highfps( doorbell.__pydantic_fields__["set_video_mode"] = Mock(final=False, frozen=False) doorbell.set_video_mode = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -391,7 +399,9 @@ async def test_switch_camera_privacy( doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) state = hass.states.get(entity_id) assert state and state.state == "off" @@ -443,7 +453,9 @@ async def test_switch_camera_privacy_already_on( doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index c34611c43a9..99f16fcbb75 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -51,8 +51,8 @@ async def test_text_camera_setup( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = ids_from_device_description( - Platform.TEXT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.TEXT, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -74,8 +74,8 @@ async def test_text_camera_set( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = ids_from_device_description( - Platform.TEXT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.TEXT, doorbell, description ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index ddd6fdf0189..6514f672d90 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -23,7 +23,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from homeassistant.helpers.entity import EntityDescription from homeassistant.util import dt as dt_util @@ -100,17 +100,43 @@ def normalize_name(name: str) -> str: return name.lower().replace(":", "").replace(" ", "_").replace("-", "_") -def ids_from_device_description( +async def async_get_translated_entity_name( + hass: HomeAssistant, platform: Platform, translation_key: str +) -> str: + """Get the translated entity name for a given platform and translation key.""" + platform_name = "unifiprotect" + + # Get the translations for the UniFi Protect integration + translations = await translation.async_get_translations( + hass, "en", "entity", {platform_name} + ) + + # Build the translation key in the format that Home Assistant uses + # component.{integration}.entity.{platform}.{translation_key}.name + full_translation_key = ( + f"component.{platform_name}.entity.{platform.value}.{translation_key}.name" + ) + + # Get the translated name, fall back to the translation key if not found + return translations.get(full_translation_key, translation_key) + + +async def ids_from_device_description( + hass: HomeAssistant, platform: Platform, device: ProtectAdoptableDeviceModel, description: EntityDescription, ) -> tuple[str, str]: - """Return expected unique_id and entity_id for a give platform/device/description combination.""" + """Return expected unique_id and entity_id using real Home Assistant translation logic.""" entity_name = normalize_name(device.display_name) if getattr(description, "translation_key", None): - description_entity_name = normalize_name(description.translation_key) + # Get the actual translated name from Home Assistant + translated_name = await async_get_translated_entity_name( + hass, platform, description.translation_key + ) + description_entity_name = normalize_name(translated_name) elif getattr(description, "device_class", None): description_entity_name = normalize_name(description.device_class) else: diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 351e11db512..1418a5b7dac 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -24,6 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityPlatformState from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component @@ -229,9 +230,11 @@ async def mock_states(hass: HomeAssistant) -> Mock: result = Mock() result.mock_mp_1 = MockMediaPlayer(hass, "mock1") + result.mock_mp_1._platform_state = EntityPlatformState.ADDED result.mock_mp_1.async_schedule_update_ha_state() result.mock_mp_2 = MockMediaPlayer(hass, "mock2") + result.mock_mp_2._platform_state = EntityPlatformState.ADDED result.mock_mp_2.async_schedule_update_ha_state() await hass.async_block_till_done() diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 55138430ca0..7cdaf4ca720 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -127,7 +127,12 @@ async def test_get_trigger_capabilities( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( @@ -157,7 +162,12 @@ async def test_get_trigger_capabilities_legacy( ) expected_capabilities = { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } triggers = await async_get_device_automations( diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 5c9ed6d4683..c57f2987c5b 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -59,7 +59,6 @@ 'entry_type': , 'hw_version': None, 'id': , - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -69,7 +68,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/uptime_kuma/__init__.py b/tests/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000..ba8ab82dc46 --- /dev/null +++ b/tests/components/uptime_kuma/__init__.py @@ -0,0 +1 @@ +"""Tests for the Uptime Kuma integration.""" diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py new file mode 100644 index 00000000000..a092c2e85ba --- /dev/null +++ b/tests/components/uptime_kuma/conftest.py @@ -0,0 +1,132 @@ +"""Common fixtures for the Uptime Kuma tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion +from pythonkuma.models import MonitorStatus +from pythonkuma.update import LatestRelease + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.service_info.hassio import HassioServiceInfo + +from tests.common import MockConfigEntry + +ADDON_SERVICE_INFO = HassioServiceInfo( + config={ + "addon": "Uptime Kuma", + CONF_URL: "http://localhost:3001/", + }, + name="Uptime Kuma", + slug="a0d7b954_uptime-kuma", + uuid="1234", +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.uptime_kuma.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Uptime Kuma configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="uptime.example.org", + data={ + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + entry_id="123456789", + ) + + +@pytest.fixture +def mock_pythonkuma() -> Generator[AsyncMock]: + """Mock pythonkuma client.""" + + monitor_1 = UptimeKumaMonitor( + monitor_id=1, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 1", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.HTTP, + monitor_url="https://example.org", + ) + monitor_2 = UptimeKumaMonitor( + monitor_id=2, + monitor_cert_days_remaining=0, + monitor_cert_is_valid=0, + monitor_hostname=None, + monitor_name="Monitor 2", + monitor_port=None, + monitor_response_time=28, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.PORT, + monitor_url=None, + ) + monitor_3 = UptimeKumaMonitor( + monitor_id=3, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 3", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.DOWN, + monitor_type=MonitorType.JSON_QUERY, + monitor_url="https://down.example.org", + ) + + with ( + patch( + "homeassistant.components.uptime_kuma.config_flow.UptimeKuma", autospec=True + ) as mock_client, + patch( + "homeassistant.components.uptime_kuma.coordinator.UptimeKuma", + new=mock_client, + ), + ): + client = mock_client.return_value + + client.metrics.return_value = { + 1: monitor_1, + 2: monitor_2, + 3: monitor_3, + } + client.version = UptimeKumaVersion( + version="2.0.0", major="2", minor="0", patch="0" + ) + + yield client + + +@pytest.fixture(autouse=True) +def mock_update_checker() -> Generator[AsyncMock]: + """Mock Update checker.""" + + with patch( + "homeassistant.components.uptime_kuma.UpdateChecker", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.latest_release.return_value = LatestRelease( + html_url="https://github.com/louislam/uptime-kuma/releases/tag/2.0.1", + name="2.0.1", + tag_name="2.0.1", + body="**RELEASE_NOTES**", + ) + + yield client diff --git a/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..97e40e821da --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + '1': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 1, + 'monitor_name': 'Monitor 1', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 1, + 'monitor_type': 'http', + 'monitor_url': '**REDACTED**', + }), + '2': dict({ + 'monitor_cert_days_remaining': 0, + 'monitor_cert_is_valid': 0, + 'monitor_hostname': None, + 'monitor_id': 2, + 'monitor_name': 'Monitor 2', + 'monitor_port': None, + 'monitor_response_time': 28, + 'monitor_status': 1, + 'monitor_type': 'port', + 'monitor_url': None, + }), + '3': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 3, + 'monitor_name': 'Monitor 3', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 0, + 'monitor_type': 'json-query', + 'monitor_url': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/uptime_kuma/snapshots/test_sensor.ambr b/tests/components/uptime_kuma/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..49a7d141c47 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_sensor.ambr @@ -0,0 +1,968 @@ +# serializer version: 1 +# name: test_setup[sensor.monitor_1_certificate_expiry-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_certificate_expiry', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_1_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'http', + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_1_monitored_url', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 1 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://example.org', + }) +# --- +# name: test_setup[sensor.monitor_1_response_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_1_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'port', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitored_hostname', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored hostname', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_hostname', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored hostname', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_hostname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitored_port', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored port', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_port', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored port', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_port', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_response_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_2_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_2_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 2 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_2_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_setup[sensor.monitor_2_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_2_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_certificate_expiry', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_3_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'json_query', + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_3_monitored_url', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 3 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://down.example.org', + }) +# --- +# name: test_setup[sensor.monitor_3_response_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_3_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'down', + }) +# --- diff --git a/tests/components/uptime_kuma/snapshots/test_update.ambr b/tests/components/uptime_kuma/snapshots/test_update.ambr new file mode 100644 index 00000000000..225584a5181 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_update.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_update[update.uptime_example_org_uptime_kuma_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Uptime Kuma version', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': '123456789_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.uptime_example_org_uptime_kuma_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/uptime_kuma/icon.png', + 'friendly_name': 'uptime.example.org Uptime Kuma version', + 'in_progress': False, + 'installed_version': '2.0.0', + 'latest_version': '2.0.1', + 'release_summary': None, + 'release_url': 'https://github.com/louislam/uptime-kuma/releases/tag/2.0.1', + 'skipped_version': None, + 'supported_features': , + 'title': 'Uptime Kuma 2.0.1', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py new file mode 100644 index 00000000000..b8b40a5b759 --- /dev/null +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -0,0 +1,482 @@ +"""Test the Uptime Kuma config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaConnectionException + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import ADDON_SERVICE_INFO + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle errors and recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_pythonkuma.metrics.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, +) -> None: + """Test config flow initiated by Supervisor.""" + mock_pythonkuma.metrics.side_effect = [UptimeKumaAuthenticationException, None] + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "Uptime Kuma"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "apikey"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "a0d7b954_uptime-kuma" + assert result["data"] == { + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_confirm_only( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow initiated by Supervisor. + + Config flow will first try to configure without authentication and if it + fails will show the form. + """ + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "Uptime Kuma"} + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "a0d7b954_uptime-kuma" + assert result["data"] == { + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: None, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_already_configured( + hass: HomeAssistant, +) -> None: + """Test config flow initiated by Supervisor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_hassio_addon_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle errors and recover.""" + mock_pythonkuma.metrics.side_effect = UptimeKumaAuthenticationException + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + mock_pythonkuma.metrics.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "apikey"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "apikey"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "a0d7b954_uptime-kuma" + assert result["data"] == { + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_ignored( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if discovery was ignored.""" + + MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IGNORE, + data={}, + entry_id="123456789", + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_update_info( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured and we update from discovery info.""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="a0d7b954_uptime-kuma", + data={ + CONF_URL: "http://localhost:80/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + entry_id="123456789", + unique_id="1234", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_URL] == "http://localhost:3001/" diff --git a/tests/components/uptime_kuma/test_diagnostics.py b/tests/components/uptime_kuma/test_diagnostics.py new file mode 100644 index 00000000000..92d98d49b75 --- /dev/null +++ b/tests/components/uptime_kuma/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests Uptime Kuma diagnostics platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py new file mode 100644 index 00000000000..61d196f0263 --- /dev/null +++ b/tests/components/uptime_kuma/test_init.py @@ -0,0 +1,164 @@ +"""Tests for the Uptime Kuma integration.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (UptimeKumaAuthenticationException, ConfigEntryState.SETUP_ERROR), + (UptimeKumaException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready.""" + + mock_pythonkuma.metrics.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is state + + +async def test_config_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, +) -> None: + """Test config entry auth error starts reauth flow.""" + + mock_pythonkuma.metrics.side_effect = UptimeKumaAuthenticationException + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_remove_stale_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can remove a device that is not in the coordinator data.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "123456789_1")} + ) + + config_entry.runtime_data.data.pop(1) + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + + assert response["success"] + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "123456789_1")}) is None + ) + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_remove_current_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we cannot remove a device if it is still active.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "123456789_1")} + ) + + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + + assert response["success"] is False + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456789_1")}) + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_remove_entry_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we cannot remove the device with the update entity.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "123456789")}) + + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + + assert response["success"] is False + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456789")}) diff --git a/tests/components/uptime_kuma/test_sensor.py b/tests/components/uptime_kuma/test_sensor.py new file mode 100644 index 00000000000..25bd7650528 --- /dev/null +++ b/tests/components/uptime_kuma/test_sensor.py @@ -0,0 +1,97 @@ +"""Test for Uptime Kuma sensor platform.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pythonkuma import MonitorStatus, UptimeKumaMonitor, UptimeKumaVersion +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma", "entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_migrate_unique_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Snapshot test states of sensor platform.""" + mock_pythonkuma.metrics.return_value = { + "Monitor": UptimeKumaMonitor( + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="1.23.16", major="1", minor="23", patch="16" + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_Monitor_status" + + mock_pythonkuma.metrics.return_value = { + 1: UptimeKumaMonitor( + monitor_id=1, + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="2.0.0-beta.3", major="2", minor="0", patch="0-beta.3" + ) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_1_status" diff --git a/tests/components/uptime_kuma/test_update.py b/tests/components/uptime_kuma/test_update.py new file mode 100644 index 00000000000..38d58b979a1 --- /dev/null +++ b/tests/components/uptime_kuma/test_update.py @@ -0,0 +1,77 @@ +"""Test the Uptime Kuma update platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import UpdateException +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def update_only() -> AsyncGenerator[None]: + """Enable only the update platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.UPDATE], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the update platform.""" + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.uptime_example_org_uptime_kuma_version", + } + ) + result = await ws_client.receive_json() + assert result["result"] == "**RELEASE_NOTES**" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_update_checker: AsyncMock, +) -> None: + """Test update entity unavailable on error.""" + + mock_update_checker.latest_release.side_effect = UpdateException + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("update.uptime_example_org_uptime_kuma_version") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index ef235bba99d..024fd1aaa7b 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -8,7 +8,7 @@ 'discovery_keys': dict({ }), 'domain': 'utility_meter', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ 'cycle': 'monthly', 'delta_values': False, diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 01fd80acc0e..0aa73d6d123 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -403,11 +403,19 @@ async def test_change_device_source( assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 1 (current) device registry + # Confirm that the configuration entry has not been added to the source entity 1 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_1.device_id # Change configuration options to use source entity 2 (with a linked device) and reload the integration previous_entity_source = source_entity_1 @@ -427,17 +435,25 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 1 (previous) device registry + # Confirm that the configuration entry is not in the source entity 1 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in to the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id # Change configuration options to use source entity 3 (without a device) and reload the integration previous_entity_source = source_entity_2 @@ -457,12 +473,20 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 2 (previous) device registry + # Confirm that the configuration entry has is not in the source entity 2 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries + # Check that the entities are no longer linked to a device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + # Confirm that there is no device with the helper configuration entry assert ( dr.async_entries_for_config_entry( @@ -489,8 +513,16 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ea4af741e19..ec7fdd1db87 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -20,7 +20,7 @@ from homeassistant.components.utility_meter import ( ) from homeassistant.components.utility_meter.config_flow import ConfigFlowHandler from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -29,7 +29,7 @@ from homeassistant.const import ( Platform, UnitOfEnergy, ) -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component @@ -108,6 +108,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -601,7 +602,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(utility_meter_config_entry.entry_id) @@ -616,7 +617,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 @pytest.mark.parametrize( @@ -642,6 +643,81 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], +) -> None: + """Test the utility_meter config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], ) -> None: """Test the utility_meter config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -667,7 +743,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor's config entry from the device, this removes the # source sensor @@ -682,7 +758,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -734,7 +818,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor from the device with patch( @@ -747,7 +831,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -805,7 +897,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries @@ -820,11 +912,19 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is moved to the other device + # Check that the entities are linked to the other device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert utility_meter_config_entry.entry_id in sensor_device_2.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -874,7 +974,7 @@ async def test_async_handle_source_entity_new_entity_id( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Change the source entity's entity ID with patch( @@ -890,9 +990,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the utility_meter config entry is updated with the new entity ID assert utility_meter_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -900,3 +1000,108 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events for entity_events in events.values(): assert entity_events == [] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_migration_2_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, + tariffs: list[str], + expected_entities: set[str], +) -> None: + """Test migration from v2.1 removes utility_meter config entry from device.""" + + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": sensor_entity_entry.entity_id, + "tariffs": tariffs, + }, + title="My utility meter", + version=2, + minor_version=1, + ) + utility_meter_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + assert utility_meter_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entities are linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + # Check that the entities are linked to the other device + entities = set() + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + entities.add(utility_meter_entity.entity_id) + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + assert entities == expected_entities + + assert utility_meter_config_entry.version == 2 + assert utility_meter_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test", + "tariffs": [], + }, + title="My utility meter", + version=3, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2de2ee553b3..f684cdb16a0 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1888,10 +1888,12 @@ async def test_bad_offset(hass: HomeAssistant) -> None: def test_calculate_adjustment_invalid_new_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: """Test that calculate_adjustment method returns None if the new state is invalid.""" mock_sensor = UtilityMeterSensor( + hass, cron_pattern=None, delta_values=False, meter_offset=DEFAULT_OFFSET, diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 381cc1caa47..330f14be507 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -134,7 +134,12 @@ async def test_get_trigger_capabilities( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } @@ -166,7 +171,12 @@ async def test_get_trigger_capabilities_legacy( ) assert capabilities == { "extra_fields": [ - {"name": "for", "optional": True, "type": "positive_time_period_dict"} + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + } ] } diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index b3e5d17c728..92fbca483fd 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import Enum +import logging from types import ModuleType from typing import Any @@ -435,3 +436,214 @@ async def test_vacuum_deprecated_state_does_not_break_state( state = hass.states.get(entity.entity_id) assert state is not None assert state.state == "cleaning" + + +@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)]) +async def test_vacuum_log_deprecated_battery_using_properties( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, + is_built_in: bool, + log_warnings: int, +) -> None: + """Test incorrectly using battery properties logs warning.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + @property + def activity(self) -> VacuumActivity: + """Return the state of the entity.""" + return VacuumActivity.CLEANING + + @property + def battery_level(self) -> int: + """Return the battery level of the vacuum.""" + return 50 + + @property + def battery_icon(self) -> str: + """Return the battery icon of the vacuum.""" + return "mdi:battery-50" + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=is_built_in, + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == log_warnings + ) + + assert ( + "integration 'test' is setting the battery_icon which has been deprecated." + in caplog.text + ) != is_built_in + assert ( + "integration 'test' is setting the battery_level which has been deprecated." + in caplog.text + ) != is_built_in + + +@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)]) +async def test_vacuum_log_deprecated_battery_using_attr( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, + is_built_in: bool, + log_warnings: int, +) -> None: + """Test incorrectly using _attr_battery_* attribute does log issue and raise repair.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + def start(self) -> None: + """Start cleaning.""" + self._attr_battery_level = 50 + self._attr_battery_icon = "mdi:battery-50" + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=is_built_in, + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + entity.start() + + assert ( + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == log_warnings + ) + + assert ( + "integration 'test' is setting the battery_level which has been deprecated." + in caplog.text + ) != is_built_in + assert ( + "integration 'test' is setting the battery_icon which has been deprecated." + in caplog.text + ) != is_built_in + + await async_start(hass, entity.entity_id) + + caplog.clear() + + await async_start(hass, entity.entity_id) + + # Test we only log once + assert ( + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == 0 + ) + + +@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 1)]) +async def test_vacuum_log_deprecated_battery_supported_feature( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, + is_built_in: bool, + log_warnings: int, +) -> None: + """Test incorrectly setting battery supported feature logs warning.""" + + class MockVacuum(StateVacuumEntity): + """Mock vacuum class.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE | VacuumEntityFeature.BATTERY + ) + _attr_name = "Testing" + + entity = MockVacuum() + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=is_built_in, + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == log_warnings + ) + + assert ( + "integration 'test' is setting the battery supported feature" in caplog.text + ) != is_built_in + + +async def test_vacuum_not_log_deprecated_battery_properties_during_init( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test not logging deprecation until after added to hass.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize a mock vacuum entity.""" + super().__init__(**kwargs) + self._attr_battery_level = 50 + + @property + def activity(self) -> VacuumActivity: + """Return the state of the entity.""" + return VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + assert entity.battery_level == 50 + + assert ( + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == 0 + ) diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index f7cbeb7a052..d909480c8ea 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -97,6 +97,7 @@ def mock_module_subdevices() -> AsyncMock: """Mock a velbus module.""" module = AsyncMock(spec=Module) module.get_type_name.return_value = "VMB2BLE" + module.get_type.return_value = "123" module.get_addresses.return_value = [88] module.get_name.return_value = "Kitchen" module.get_serial.return_value = "a1b2c3d4e5f6" @@ -138,7 +139,7 @@ def mock_temperature() -> AsyncMock: channel.get_module_sw_version.return_value = "3.0.0" channel.get_module_serial.return_value = "asdfghjk" channel.get_module_type.return_value = 1 - channel.is_sub_device.return_value = False + channel.is_sub_device.return_value = True channel.is_counter_channel.return_value = False channel.get_class.return_value = "temperature" channel.get_unit.return_value = "°C" @@ -184,7 +185,7 @@ def mock_select() -> AsyncMock: channel.get_full_name.return_value = "Kitchen" channel.get_module_sw_version.return_value = "1.1.1" channel.get_module_serial.return_value = "qwerty1234567" - channel.is_sub_device.return_value = False + channel.is_sub_device.return_value = True channel.get_options.return_value = ["none", "summer", "winter", "holiday"] channel.get_selected_program.return_value = "winter" return channel diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 1e17753a02f..0383abc0313 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -18,7 +18,6 @@ '1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -28,165 +27,9 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'velbus', - '88-9', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Velleman', - 'model': 'VMB2BLE', - 'model_id': '10', - 'name': 'Basement', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': '1234', - 'suggested_area': None, - 'sw_version': '1.0.1', - 'via_device_id': , - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'velbus', - '88-11', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Velleman', - 'model': 'VMB2BLE', - 'model_id': '10', - 'name': 'Basement', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': '12345', - 'suggested_area': None, - 'sw_version': '1.0.1', - 'via_device_id': , - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'velbus', - '88-10', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Velleman', - 'model': 'VMBDN1', - 'model_id': '9', - 'name': 'Dimmer full name', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': 'a1b2c3d4e5f6g7', - 'suggested_area': None, - 'sw_version': '1.0.0', - 'via_device_id': , - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'velbus', - '88-2', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Velleman', - 'model': 'VMB7IN', - 'model_id': '4', - 'name': 'Input', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': 'a1b2c3d4e5f6', - 'suggested_area': None, - 'sw_version': '1.0.0', - 'via_device_id': , - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'velbus', - '88', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Velleman', - 'model': 'VMB4GPO', - 'model_id': '1', - 'name': 'Living room', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': 'asdfghjk', - 'suggested_area': None, - 'sw_version': '3.0.0', - 'via_device_id': None, - }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -204,7 +47,6 @@ '2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -214,10 +56,183 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '123', + 'name': 'Kitchen (VMB2BLE)', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'sw_version': '2.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-10', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMBDN1', + 'model_id': '9', + 'name': 'Dimmer full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6g7', + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-11', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '12345', + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-2', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '4', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-3', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4GPO', + 'model_id': '1', + 'name': 'Living room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'asdfghjk', + 'sw_version': '3.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-33', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYNO', + 'model_id': '3', + 'name': 'Kitchen', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'qwerty1234567', + 'sw_version': '1.1.1', + 'via_device_id': , + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -235,7 +250,6 @@ '88-55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -245,7 +259,35 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'qwerty123', - 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-9', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234', 'sw_version': '1.0.1', 'via_device_id': , }), diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 2d28ba81cb1..fc9046f977f 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -176,7 +176,8 @@ async def test_device_registry( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert device_entries == snapshot + # Sort by identifier to ensure consistent order in snapshot + assert sorted(device_entries, key=lambda x: list(x.identifiers)[0][1]) == snapshot device_parent = device_registry.async_get_device(identifiers={(DOMAIN, "88")}) assert device_parent.via_device_id is None diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py index 94ba91e6dc3..afcd79be7de 100644 --- a/tests/components/velbus/test_services.py +++ b/tests/components/velbus/test_services.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.components.velbus.const import ( CONF_CONFIG_ENTRY, - CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, SERVICE_CLEAR_CACHE, @@ -18,57 +17,12 @@ from homeassistant.components.velbus.const import ( from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir from . import init_integration from tests.common import MockConfigEntry -async def test_global_services_with_interface( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test services directed at the bus with an interface parameter.""" - await init_integration(hass, config_entry) - - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN, - {CONF_INTERFACE: config_entry.data["port"]}, - blocking=True, - ) - config_entry.runtime_data.controller.scan.assert_called_once_with() - assert issue_registry.async_get_issue(DOMAIN, "deprecated_interface_parameter") - - await hass.services.async_call( - DOMAIN, - SERVICE_SYNC, - {CONF_INTERFACE: config_entry.data["port"]}, - blocking=True, - ) - config_entry.runtime_data.controller.sync_clock.assert_called_once_with() - - # Test invalid interface - with pytest.raises(vol.error.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN, - {CONF_INTERFACE: "nonexistent"}, - blocking=True, - ) - - # Test missing interface - with pytest.raises(vol.error.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN, - {}, - blocking=True, - ) - - async def test_global_survices_with_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index c88a21d2bba..1b7066577ad 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -1,16 +1,18 @@ """Configuration for Velux tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.velux import DOMAIN +from homeassistant.components.velux.binary_sensor import Window from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from tests.common import MockConfigEntry +# Fixtures for the config flow tests @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -59,3 +61,52 @@ def mock_discovered_config_entry() -> MockConfigEntry: }, unique_id="VELUX_KLF_ABCD", ) + + +# fixtures for the binary sensor tests +@pytest.fixture +def mock_window() -> AsyncMock: + """Create a mock Velux window with a rain sensor.""" + window = AsyncMock(spec=Window, autospec=True) + window.name = "Test Window" + window.rain_sensor = True + window.serial_number = "123456789" + window.get_limitation.return_value = MagicMock(min_value=0) + return window + + +@pytest.fixture +def mock_pyvlx(mock_window: MagicMock) -> MagicMock: + """Create the library mock.""" + pyvlx = MagicMock() + pyvlx.nodes = [mock_window] + pyvlx.load_scenes = AsyncMock() + pyvlx.load_nodes = AsyncMock() + pyvlx.disconnect = AsyncMock() + return pyvlx + + +@pytest.fixture +def mock_module(mock_pyvlx: MagicMock) -> Generator[AsyncMock]: + """Create the Velux module mock.""" + with ( + patch( + "homeassistant.components.velux.VeluxModule", + autospec=True, + ) as mock_velux, + ): + module = mock_velux.return_value + module.pyvlx = mock_pyvlx + yield module + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "testhost", + CONF_PASSWORD: "testpw", + }, + ) diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py new file mode 100644 index 00000000000..8eb065a5a46 --- /dev/null +++ b/tests/components/velux/test_binary_sensor.py @@ -0,0 +1,50 @@ +"""Tests for the Velux binary sensor platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("mock_module") +async def test_rain_sensor_state( + hass: HomeAssistant, + mock_window: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the rain sensor.""" + mock_config_entry.add_to_hass(hass) + + test_entity_id = "binary_sensor.test_window_rain_sensor" + + with ( + patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]), + ): + # setup config entry + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # simulate no rain detected + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OFF + + # simulate rain detected + mock_window.get_limitation.return_value.min_value = 93 + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_ON diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index fe330b82ca7..86cfa8198ba 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -119,7 +117,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -129,7 +126,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -219,7 +215,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -229,7 +224,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -321,7 +315,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -331,7 +324,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -423,7 +415,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -433,7 +424,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -462,7 +452,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -472,7 +461,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -501,7 +489,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -511,7 +498,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -540,7 +526,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -550,7 +535,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -579,7 +563,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -589,7 +572,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -618,7 +600,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -628,7 +609,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -724,7 +704,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -734,7 +713,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -763,7 +741,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -773,7 +750,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 20bf56ef9c4..df2dad8825d 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -57,7 +55,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -67,7 +64,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -96,7 +92,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -106,7 +101,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -135,7 +129,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -145,7 +138,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -174,7 +166,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -184,7 +175,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -267,7 +257,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -277,7 +266,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -362,7 +350,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -372,7 +359,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -401,7 +387,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -411,7 +396,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -440,7 +424,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -450,7 +433,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -479,7 +461,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -489,7 +470,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -518,7 +498,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -528,7 +507,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -626,7 +604,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -636,7 +613,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index a47de22f68b..e29255cdc72 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -153,7 +151,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -163,7 +160,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -242,7 +238,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -252,7 +247,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -361,7 +355,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '400s-purifier-pm25', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), ]) # --- @@ -399,7 +393,7 @@ 'device_class': 'pm25', 'friendly_name': 'Air Purifier 400s PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.air_purifier_400s_pm2_5', @@ -428,7 +422,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -438,7 +431,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -547,7 +539,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '600s-purifier-pm25', - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), ]) # --- @@ -585,7 +577,7 @@ 'device_class': 'pm25', 'friendly_name': 'Air Purifier 600s PM2.5', 'state_class': , - 'unit_of_measurement': 'µg/m³', + 'unit_of_measurement': 'μg/m³', }), 'context': , 'entity_id': 'sensor.air_purifier_600s_pm2_5', @@ -614,7 +606,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -624,7 +615,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -653,7 +643,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -663,7 +652,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -692,7 +680,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -702,7 +689,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -782,7 +768,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -792,7 +777,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -872,7 +856,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -882,7 +865,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -1235,7 +1217,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -1245,7 +1226,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -1274,7 +1254,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -1284,7 +1263,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -1313,7 +1291,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -1323,7 +1300,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index edd2eee8b1f..e7917397063 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -28,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -103,7 +101,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -113,7 +110,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -188,7 +184,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -198,7 +193,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -273,7 +267,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -283,7 +276,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -358,7 +350,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -368,7 +359,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -397,7 +387,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -407,7 +396,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -436,7 +424,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -446,7 +433,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -521,7 +507,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -531,7 +516,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -606,7 +590,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -616,7 +599,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -692,7 +674,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -702,7 +683,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -777,7 +757,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -787,7 +766,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -816,7 +794,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -826,7 +803,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py new file mode 100644 index 00000000000..acd608b8d26 --- /dev/null +++ b/tests/components/volvo/__init__.py @@ -0,0 +1,58 @@ +"""Tests for the Volvo integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from volvocarsapi.models import VolvoCarsValueField + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType, json_loads_object + +from tests.common import async_load_fixture + +_MODEL_SPECIFIC_RESPONSES = { + "ex30_2024": ["energy_capabilities", "energy_state", "statistics", "vehicle"], + "s90_diesel_2018": ["diagnostics", "statistics", "vehicle"], + "xc40_electric_2024": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], + "xc60_phev_2020": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], + "xc90_petrol_2019": ["commands", "statistics", "vehicle"], +} + + +async def async_load_fixture_as_json( + hass: HomeAssistant, name: str, model: str +) -> JsonObjectType: + """Load a JSON object from a fixture.""" + if name in _MODEL_SPECIFIC_RESPONSES[model]: + name = f"{model}/{name}" + + fixture = await async_load_fixture(hass, f"{name}.json", DOMAIN) + return json_loads_object(fixture) + + +async def async_load_fixture_as_value_field( + hass: HomeAssistant, name: str, model: str +) -> dict[str, VolvoCarsValueField]: + """Load a `VolvoCarsValueField` object from a fixture.""" + data = await async_load_fixture_as_json(hass, name, model) + return {key: VolvoCarsValueField.from_dict(value) for key, value in data.items()} + + +def configure_mock( + mock: AsyncMock, *, return_value: Any = None, side_effect: Any = None +) -> None: + """Reconfigure mock.""" + mock.reset_mock() + mock.side_effect = side_effect + mock.return_value = return_value diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py new file mode 100644 index 00000000000..fedd3a6ec3f --- /dev/null +++ b/tests/components/volvo/conftest.py @@ -0,0 +1,185 @@ +"""Define fixtures for Volvo unit tests.""" + +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from unittest.mock import AsyncMock, patch + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import TOKEN_URL +from volvocarsapi.models import ( + VolvoCarsAvailableCommand, + VolvoCarsLocation, + VolvoCarsValueStatusField, + VolvoCarsVehicle, +) + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import async_load_fixture_as_json, async_load_fixture_as_value_field +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DEFAULT_VIN, + MOCK_ACCESS_TOKEN, + SERVER_TOKEN_RESPONSE, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(params=[DEFAULT_MODEL]) +def full_model(request: pytest.FixtureRequest) -> str: + """Define which model to use when running the test. Use as a decorator.""" + return request.param + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DEFAULT_VIN, + data={ + "auth_implementation": DOMAIN, + CONF_API_KEY: DEFAULT_API_KEY, + CONF_VIN: DEFAULT_VIN, + CONF_TOKEN: { + "access_token": MOCK_ACCESS_TOKEN, + "refresh_token": "mock-refresh-token", + "expires_at": 123456789, + }, + }, + ) + + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(autouse=True) +async def mock_api(hass: HomeAssistant, full_model: str) -> AsyncGenerator[AsyncMock]: + """Mock the Volvo API.""" + with patch( + "homeassistant.components.volvo.VolvoCarsApi", + autospec=True, + ) as mock_api: + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) + vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + + commands_data = ( + await async_load_fixture_as_json(hass, "commands", full_model) + ).get("data") + commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] + + location_data = await async_load_fixture_as_json(hass, "location", full_model) + location = {"location": VolvoCarsLocation.from_dict(location_data)} + + availability = await async_load_fixture_as_value_field( + hass, "availability", full_model + ) + brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model) + diagnostics = await async_load_fixture_as_value_field( + hass, "diagnostics", full_model + ) + doors = await async_load_fixture_as_value_field(hass, "doors", full_model) + energy_capabilities = await async_load_fixture_as_json( + hass, "energy_capabilities", full_model + ) + energy_state_data = await async_load_fixture_as_json( + hass, "energy_state", full_model + ) + energy_state = { + key: VolvoCarsValueStatusField.from_dict(value) + for key, value in energy_state_data.items() + } + engine_status = await async_load_fixture_as_value_field( + hass, "engine_status", full_model + ) + engine_warnings = await async_load_fixture_as_value_field( + hass, "engine_warnings", full_model + ) + fuel_status = await async_load_fixture_as_value_field( + hass, "fuel_status", full_model + ) + odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model) + recharge_status = await async_load_fixture_as_value_field( + hass, "recharge_status", full_model + ) + statistics = await async_load_fixture_as_value_field( + hass, "statistics", full_model + ) + tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model) + warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model) + windows = await async_load_fixture_as_value_field(hass, "windows", full_model) + + api: VolvoCarsApi = mock_api.return_value + api.async_get_brakes_status = AsyncMock(return_value=brakes) + api.async_get_command_accessibility = AsyncMock(return_value=availability) + api.async_get_commands = AsyncMock(return_value=commands) + api.async_get_diagnostics = AsyncMock(return_value=diagnostics) + api.async_get_doors_status = AsyncMock(return_value=doors) + api.async_get_energy_capabilities = AsyncMock(return_value=energy_capabilities) + api.async_get_energy_state = AsyncMock(return_value=energy_state) + api.async_get_engine_status = AsyncMock(return_value=engine_status) + api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) + api.async_get_fuel_status = AsyncMock(return_value=fuel_status) + api.async_get_location = AsyncMock(return_value=location) + api.async_get_odometer = AsyncMock(return_value=odometer) + api.async_get_recharge_status = AsyncMock(return_value=recharge_status) + api.async_get_statistics = AsyncMock(return_value=statistics) + api.async_get_tyre_states = AsyncMock(return_value=tyres) + api.async_get_vehicle_details = AsyncMock(return_value=vehicle) + api.async_get_warnings = AsyncMock(return_value=warnings) + api.async_get_window_states = AsyncMock(return_value=windows) + + yield api + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + + async def run() -> bool: + aioclient_mock.post( + TOKEN_URL, + json=SERVER_TOKEN_RESPONSE, + ) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.volvo.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/volvo/const.py b/tests/components/volvo/const.py new file mode 100644 index 00000000000..df18bacb2b0 --- /dev/null +++ b/tests/components/volvo/const.py @@ -0,0 +1,19 @@ +"""Define const for Volvo unit tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +DEFAULT_API_KEY = "abcdef0123456879abcdef" +DEFAULT_MODEL = "xc40_electric_2024" +DEFAULT_VIN = "YV1ABCDEFG1234567" + +MOCK_ACCESS_TOKEN = "mock-access-token" + +REDIRECT_URI = "https://example.com/auth/external/callback" + +SERVER_TOKEN_RESPONSE = { + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", + "token_type": "Bearer", + "expires_in": 60, +} diff --git a/tests/components/volvo/fixtures/availability.json b/tests/components/volvo/fixtures/availability.json new file mode 100644 index 00000000000..264f4d54360 --- /dev/null +++ b/tests/components/volvo/fixtures/availability.json @@ -0,0 +1,6 @@ +{ + "availabilityStatus": { + "value": "AVAILABLE", + "timestamp": "2024-12-30T14:32:26.169Z" + } +} diff --git a/tests/components/volvo/fixtures/brakes.json b/tests/components/volvo/fixtures/brakes.json new file mode 100644 index 00000000000..6fe3b3b328c --- /dev/null +++ b/tests/components/volvo/fixtures/brakes.json @@ -0,0 +1,6 @@ +{ + "brakeFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/commands.json b/tests/components/volvo/fixtures/commands.json new file mode 100644 index 00000000000..5d21861801f --- /dev/null +++ b/tests/components/volvo/fixtures/commands.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/diagnostics.json b/tests/components/volvo/fixtures/diagnostics.json new file mode 100644 index 00000000000..100af71b9e3 --- /dev/null +++ b/tests/components/volvo/fixtures/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 23, + "unit": "months", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/doors.json b/tests/components/volvo/fixtures/doors.json new file mode 100644 index 00000000000..268d9fec467 --- /dev/null +++ b/tests/components/volvo/fixtures/doors.json @@ -0,0 +1,34 @@ +{ + "centralLock": { + "value": "LOCKED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "hood": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tailgate": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tankLid": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + } +} diff --git a/tests/components/volvo/fixtures/energy_capabilities.json b/tests/components/volvo/fixtures/energy_capabilities.json new file mode 100644 index 00000000000..16ba914e343 --- /dev/null +++ b/tests/components/volvo/fixtures/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": false, + "batteryChargeLevel": { + "isSupported": false + }, + "electricRange": { + "isSupported": false + }, + "chargerConnectionStatus": { + "isSupported": false + }, + "chargingSystemStatus": { + "isSupported": false + }, + "chargingType": { + "isSupported": false + }, + "chargerPowerStatus": { + "isSupported": false + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": false + }, + "targetBatteryChargeLevel": { + "isSupported": false + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/energy_state.json b/tests/components/volvo/fixtures/energy_state.json new file mode 100644 index 00000000000..31d717c4cce --- /dev/null +++ b/tests/components/volvo/fixtures/energy_state.json @@ -0,0 +1,42 @@ +{ + "batteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "electricRange": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargerConnectionStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingType": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargerPowerStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "targetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + } +} diff --git a/tests/components/volvo/fixtures/engine_status.json b/tests/components/volvo/fixtures/engine_status.json new file mode 100644 index 00000000000..daac36b6a26 --- /dev/null +++ b/tests/components/volvo/fixtures/engine_status.json @@ -0,0 +1,6 @@ +{ + "engineStatus": { + "value": "STOPPED", + "timestamp": "2024-12-30T15:00:00.000Z" + } +} diff --git a/tests/components/volvo/fixtures/engine_warnings.json b/tests/components/volvo/fixtures/engine_warnings.json new file mode 100644 index 00000000000..d431355fd24 --- /dev/null +++ b/tests/components/volvo/fixtures/engine_warnings.json @@ -0,0 +1,10 @@ +{ + "oilLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineCoolantLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json new file mode 100644 index 00000000000..f3aff11585d --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": true + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_state.json b/tests/components/volvo/fixtures/ex30_2024/energy_state.json new file mode 100644 index 00000000000..5973100d4ea --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/energy_state.json @@ -0,0 +1,56 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 90.0, + "unit": "percentage", + "updatedAt": "2025-08-07T14:30:32Z" + }, + "electricRange": { + "status": "OK", + "value": 327, + "unit": "km", + "updatedAt": "2025-08-07T14:30:32Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "CONNECTED", + "updatedAt": "2025-08-07T14:30:32Z" + }, + "chargingStatus": { + "status": "OK", + "value": "DONE", + "updatedAt": "2025-08-07T14:30:32Z" + }, + "chargingType": { + "status": "OK", + "value": "AC", + "updatedAt": "2025-08-07T14:30:32Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "FAULT", + "updatedAt": "2025-08-07T14:30:32Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 2, + "unit": "minutes", + "updatedAt": "2025-08-07T14:30:32Z" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "targetBatteryChargeLevel": { + "status": "OK", + "value": 90, + "unit": "percentage", + "updatedAt": "2025-08-07T14:49:50Z" + }, + "chargingPower": { + "status": "ERROR", + "code": "PROPERTY_NOT_FOUND", + "message": "No valid value could be found for the requested property" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/statistics.json b/tests/components/volvo/fixtures/ex30_2024/statistics.json new file mode 100644 index 00000000000..9e2f32bdcf2 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/statistics.json @@ -0,0 +1,32 @@ +{ + "averageEnergyConsumption": { + "value": 22.6, + "unit": "kWh/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyBattery": { + "value": 250, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/vehicle.json b/tests/components/volvo/fixtures/ex30_2024/vehicle.json new file mode 100644 index 00000000000..dc47b5bb341 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "NONE", + "externalColour": "Crystal White Pearl", + "batteryCapacityKWH": 66.0, + "images": { + "exteriorImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/exterior/studio/right/transparent_exterior-studio-right_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920", + "internalImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/interior/studio/side/interior-studio-side_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920" + }, + "descriptions": { + "model": "EX30", + "upholstery": "R310", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/fuel_status.json b/tests/components/volvo/fixtures/fuel_status.json new file mode 100644 index 00000000000..a55f14467fe --- /dev/null +++ b/tests/components/volvo/fixtures/fuel_status.json @@ -0,0 +1,12 @@ +{ + "fuelAmount": { + "value": "47.3", + "unit": "l", + "timestamp": "2020-11-19T21:23:24.123Z" + }, + "batteryChargeLevel": { + "value": "87.3", + "unit": "%", + "timestamp": "2020-11-19T21:23:24.123Z" + } +} diff --git a/tests/components/volvo/fixtures/location.json b/tests/components/volvo/fixtures/location.json new file mode 100644 index 00000000000..eec49f8a66b --- /dev/null +++ b/tests/components/volvo/fixtures/location.json @@ -0,0 +1,11 @@ +{ + "type": "Feature", + "properties": { + "timestamp": "2024-12-30T15:00:00.000Z", + "heading": "90" + }, + "geometry": { + "type": "Point", + "coordinates": [11.849843629550225, 57.72537482589284, 0.0] + } +} diff --git a/tests/components/volvo/fixtures/odometer.json b/tests/components/volvo/fixtures/odometer.json new file mode 100644 index 00000000000..a9196faaa7d --- /dev/null +++ b/tests/components/volvo/fixtures/odometer.json @@ -0,0 +1,7 @@ +{ + "odometer": { + "value": 30000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/recharge_status.json b/tests/components/volvo/fixtures/recharge_status.json new file mode 100644 index 00000000000..5e9fed0803c --- /dev/null +++ b/tests/components/volvo/fixtures/recharge_status.json @@ -0,0 +1,25 @@ +{ + "estimatedChargingTime": { + "value": "780", + "unit": "minutes", + "timestamp": "2024-12-30T14:30:08Z" + }, + "batteryChargeLevel": { + "value": "58.0", + "unit": "percentage", + "timestamp": "2024-12-30T14:30:08Z" + }, + "electricRange": { + "value": "250", + "unit": "kilometers", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingSystemStatus": { + "value": "CHARGING_SYSTEM_IDLE", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingConnectionStatus": { + "value": "CONNECTION_STATUS_CONNECTED_AC", + "timestamp": "2024-12-30T14:30:08Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json new file mode 100644 index 00000000000..738eb3c8966 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 17, + "unit": "days", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json new file mode 100644 index 00000000000..9f6760451ed --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 7.23, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyTank": { + "value": 147, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json new file mode 100644 index 00000000000..429964991e7 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2018, + "gearbox": "AUTOMATIC", + "fuelType": "DIESEL", + "externalColour": "Electric Silver", + "images": { + "exteriorImageUrl": "", + "internalImageUrl": "" + }, + "descriptions": { + "model": "S90", + "upholstery": "null", + "steering": "RIGHT" + } +} diff --git a/tests/components/volvo/fixtures/tyres.json b/tests/components/volvo/fixtures/tyres.json new file mode 100644 index 00000000000..c414c85203f --- /dev/null +++ b/tests/components/volvo/fixtures/tyres.json @@ -0,0 +1,18 @@ +{ + "frontLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "frontRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/warnings.json b/tests/components/volvo/fixtures/warnings.json new file mode 100644 index 00000000000..5bec30ed4b3 --- /dev/null +++ b/tests/components/volvo/fixtures/warnings.json @@ -0,0 +1,94 @@ +{ + "brakeLightCenterWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightFrontWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightRearWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "registrationPlateLightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "sideMarkLightsWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "hazardLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "reverseLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/windows.json b/tests/components/volvo/fixtures/windows.json new file mode 100644 index 00000000000..cd399b3bbe8 --- /dev/null +++ b/tests/components/volvo/fixtures/windows.json @@ -0,0 +1,22 @@ +{ + "frontLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "frontRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "sunroof": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:28:12.202Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json new file mode 100644 index 00000000000..3523d51e071 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": true + }, + "chargingPower": { + "isSupported": true + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json new file mode 100644 index 00000000000..bac596857b0 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json @@ -0,0 +1,58 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 53, + "unit": "percentage", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "electricRange": { + "status": "OK", + "value": 150, + "unit": "mi", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "CONNECTED", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingStatus": { + "status": "OK", + "value": "CHARGING", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingType": { + "status": "OK", + "value": "AC", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "PROVIDING_POWER", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 1440, + "unit": "minutes", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingCurrentLimit": { + "status": "OK", + "value": 32, + "unit": "ampere", + "updatedAt": "2024-03-05T08:38:44Z" + }, + "targetBatteryChargeLevel": { + "status": "OK", + "value": 90, + "unit": "percentage", + "updatedAt": "2024-09-22T09:40:12Z" + }, + "chargingPower": { + "status": "OK", + "value": 1386, + "unit": "watts", + "updatedAt": "2025-07-02T08:51:23Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json b/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json new file mode 100644 index 00000000000..9e2f32bdcf2 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json @@ -0,0 +1,32 @@ +{ + "averageEnergyConsumption": { + "value": 22.6, + "unit": "kWh/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyBattery": { + "value": 250, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json new file mode 100644 index 00000000000..8b36c06f681 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "ELECTRIC", + "externalColour": "Silver Dawn", + "batteryCapacityKWH": 81.608, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/exterior-v4/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/interior-v4/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC40", + "upholstery": "null", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json new file mode 100644 index 00000000000..331795f545b --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": false + }, + "electricRange": { + "isSupported": false + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": false + }, + "chargerPowerStatus": { + "isSupported": false + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": false + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/energy_state.json b/tests/components/volvo/fixtures/xc60_phev_2020/energy_state.json new file mode 100644 index 00000000000..e198bfc8330 --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/energy_state.json @@ -0,0 +1,53 @@ +{ + "batteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "electricRange": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "DISCONNECTED", + "updatedAt": "2025-08-07T20:29:18Z" + }, + "chargingStatus": { + "status": "OK", + "value": "IDLE", + "updatedAt": "2025-08-07T20:29:18Z" + }, + "chargingType": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "chargerPowerStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "targetBatteryChargeLevel": { + "status": "OK", + "value": 80, + "unit": "percentage", + "updatedAt": "2024-09-22T09:40:12Z" + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + } +} diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/statistics.json b/tests/components/volvo/fixtures/xc60_phev_2020/statistics.json new file mode 100644 index 00000000000..91384f2d13e --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 4.0, + "unit": "l/100km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "averageSpeed": { + "value": 65, + "unit": "km/h", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "tripMeterManual": { + "value": 219.7, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "tripMeterAutomatic": { + "value": 0.0, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "distanceToEmptyTank": { + "value": 920, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "distanceToEmptyBattery": { + "value": 29, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + } +} diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/vehicle.json b/tests/components/volvo/fixtures/xc60_phev_2020/vehicle.json new file mode 100644 index 00000000000..734672eb59e --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2020, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL/ELECTRIC", + "externalColour": "Bright Silver", + "batteryCapacityKWH": 11.832, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY20_0000/123/exterior-v1/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY20_0000/123/interior-v1/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC60", + "upholstery": "CHARCOAL/LEABR3/CHARC/SPO", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json new file mode 100644 index 00000000000..8f5e62df1ed --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json @@ -0,0 +1,44 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + }, + { + "command": "ENGINE_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-start" + }, + { + "command": "ENGINE_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json b/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json new file mode 100644 index 00000000000..1a7744a4d49 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 9.59, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 66, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 77, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyTank": { + "value": 253, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + }, + "tripMeterManual": { + "value": 178.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 4.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json new file mode 100644 index 00000000000..1d4b1250b8a --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2019, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL", + "externalColour": "Passion Red Solid", + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/exterior/MY17_0000/123/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/interior/MY17_0000/123/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC90", + "upholstery": "CHARCOAL/LEABR/CHARC/S", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9d709a27fc3 --- /dev/null +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -0,0 +1,4781 @@ +# serializer version: 1 +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo EX30 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_ex30_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo EX30 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66.0', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_ex30_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'yv1abcdefg1234567_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Volvo EX30 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_power_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging power status', + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fault', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'done', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ac', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_estimated_charging_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_target_battery_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo EX30 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.6', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo EX30 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo S90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_s90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo S90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '147', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_fuel_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo S90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo S90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo S90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo S90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.23', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo S90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc40_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC40 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '81.608', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc40_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging limit', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_current_limit', + 'unique_id': 'yv1abcdefg1234567_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Volvo XC40 Charging limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'yv1abcdefg1234567_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Volvo XC40 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1386', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_power_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging power status', + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'providing_power', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ac', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_estimated_charging_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1440', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_target_battery_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC40 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.6', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC40 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC60 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc60_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC60 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.832', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc60_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC60 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_charging_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC60 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disconnected', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC60 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '920', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_fuel_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC60 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_target_battery_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC60 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC60 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC60 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC60 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC60 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '219.7', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '253', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_fuel_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '77', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.2', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.59', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '178.9', + }) +# --- diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py new file mode 100644 index 00000000000..3129b1383fe --- /dev/null +++ b/tests/components/volvo/test_config_flow.py @@ -0,0 +1,350 @@ +"""Test the Volvo config flow.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL +from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle +from volvocarsapi.scopes import DEFAULT_SCOPES +from yarl import URL + +from homeassistant import config_entries +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import async_load_fixture_as_json, configure_mock +from .const import ( + CLIENT_ID, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DEFAULT_VIN, + REDIRECT_URI, + SERVER_TOKEN_RESPONSE, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check full flow.""" + result = await _async_run_flow_to_completion( + hass, config_flow, mock_config_flow_api + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_API_KEY] == DEFAULT_API_KEY + assert result["data"][CONF_VIN] == DEFAULT_VIN + assert result["context"]["unique_id"] == DEFAULT_VIN + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_single_vin_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check flow where API returns a single VIN.""" + _configure_mock_vehicles_success(mock_config_flow_api, single_vin=True) + + # Since there is only one VIN, the api_key step is the only step + result = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize(("api_key_failure"), [pytest.param(True), pytest.param(False)]) +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + mock_config_flow_api: VolvoCarsApi, + api_key_failure: bool, +) -> None: + """Test reauthentication flow.""" + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await _async_run_flow_to_completion( + hass, + result, + mock_config_flow_api, + has_vin_step=False, + is_reauth=True, + api_key_failure=api_key_failure, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_no_stale_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test if reauthentication flow does not use stale data.""" + old_access_token = mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + + with patch( + "homeassistant.components.volvo.config_flow._create_volvo_cars_api", + return_value=mock_config_flow_api, + ) as mock_create_volvo_cars_api: + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await _async_run_flow_to_completion( + hass, + result, + mock_config_flow_api, + has_vin_step=False, + is_reauth=True, + ) + + assert mock_create_volvo_cars_api.called + call = mock_create_volvo_cars_api.call_args_list[0] + access_token_arg = call.args[1] + assert old_access_token != access_token_arg + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test reconfiguration flow.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.usefixtures("current_request_with_host", "mock_config_entry") +async def test_unique_id_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test unique ID flow.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await _async_run_flow_to_completion( + hass, config_flow, mock_config_flow_api + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_api_failure_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check flow where API throws an exception.""" + _configure_mock_vehicles_failure(mock_config_flow_api) + + result = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_load_vehicles" + assert result["step_id"] == "api_key" + + result = await _async_run_flow_to_completion( + hass, result, mock_config_flow_api, configure=False + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.fixture +async def config_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> config_entries.ConfigFlowResult: + """Initialize a new config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + result_url = URL(result["url"]) + assert f"{result_url.origin()}{result_url.path}" == AUTHORIZE_URL + assert result_url.query["response_type"] == "code" + assert result_url.query["client_id"] == CLIENT_ID + assert result_url.query["redirect_uri"] == REDIRECT_URI + assert result_url.query["state"] == state + assert result_url.query["code_challenge"] + assert result_url.query["code_challenge_method"] == "S256" + assert result_url.query["scope"] == " ".join(DEFAULT_SCOPES) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + return result + + +@pytest.fixture +async def mock_config_flow_api(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock API used in config flow.""" + with patch( + "homeassistant.components.volvo.config_flow.VolvoCarsApi", + autospec=True, + ) as mock_api: + api: VolvoCarsApi = mock_api.return_value + + _configure_mock_vehicles_success(api) + + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", DEFAULT_MODEL) + configure_mock( + api.async_get_vehicle_details, + return_value=VolvoCarsVehicle.from_dict(vehicle_data), + ) + + yield api + + +@pytest.fixture(autouse=True) +async def mock_auth_client( + aioclient_mock: AiohttpClientMocker, +) -> AsyncGenerator[AsyncMock]: + """Mock auth requests.""" + aioclient_mock.clear_requests() + aioclient_mock.post( + TOKEN_URL, + json=SERVER_TOKEN_RESPONSE, + ) + + +async def _async_run_flow_to_completion( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, + *, + configure: bool = True, + has_vin_step: bool = True, + is_reauth: bool = False, + api_key_failure: bool = False, +) -> ConfigFlowResult: + if configure: + if api_key_failure: + _configure_mock_vehicles_failure(mock_config_flow_api) + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"] + ) + + if is_reauth and not api_key_failure: + return config_flow + + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "api_key" + + _configure_mock_vehicles_success(mock_config_flow_api) + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + if has_vin_step: + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "vin" + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_VIN: DEFAULT_VIN} + ) + + return config_flow + + +def _configure_mock_vehicles_success( + mock_config_flow_api: VolvoCarsApi, single_vin: bool = False +) -> None: + vins = [{"vin": DEFAULT_VIN}] + + if not single_vin: + vins.append({"vin": "YV10000000AAAAAAA"}) + + configure_mock(mock_config_flow_api.async_get_vehicles, return_value=vins) + + +def _configure_mock_vehicles_failure(mock_config_flow_api: VolvoCarsApi) -> None: + configure_mock( + mock_config_flow_api.async_get_vehicles, side_effect=VolvoApiException() + ) diff --git a/tests/components/volvo/test_coordinator.py b/tests/components/volvo/test_coordinator.py new file mode 100644 index 00000000000..271693a18d1 --- /dev/null +++ b/tests/components/volvo/test_coordinator.py @@ -0,0 +1,151 @@ +"""Test Volvo coordinator.""" + +from collections.abc import Awaitable, Callable +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsValueField, +) + +from homeassistant.components.volvo.coordinator import VERY_SLOW_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import configure_mock + +from tests.common import async_fire_time_changed + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_coordinator_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test coordinator update.""" + assert await setup_integration() + + sensor_id = "sensor.volvo_xc40_odometer" + interval = timedelta(minutes=VERY_SLOW_INTERVAL) + value = {"odometer": VolvoCarsValueField(value=30000, unit="km")} + mock_method: AsyncMock = mock_api.async_get_odometer + + state = hass.states.get(sensor_id) + assert state.state == "30000" + + value["odometer"].value = 30001 + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30001" + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_coordinator_with_errors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test coordinator with errors.""" + assert await setup_integration() + + sensor_id = "sensor.volvo_xc40_odometer" + interval = timedelta(minutes=VERY_SLOW_INTERVAL) + value = {"odometer": VolvoCarsValueField(value=30000, unit="km")} + mock_method: AsyncMock = mock_api.async_get_odometer + + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=VolvoApiException()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=Exception()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=VolvoAuthException()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_update_coordinator_all_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test API returning error for all calls during coordinator update.""" + assert await setup_integration() + + _mock_api_failure(mock_api) + freezer.tick(timedelta(minutes=VERY_SLOW_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for state in hass.states.async_all(): + assert state.state == STATE_UNAVAILABLE + + +def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock: + """Mock the Volvo API so that it raises an exception for all calls.""" + + mock_api.async_get_brakes_status.side_effect = VolvoApiException() + mock_api.async_get_command_accessibility.side_effect = VolvoApiException() + mock_api.async_get_commands.side_effect = VolvoApiException() + mock_api.async_get_diagnostics.side_effect = VolvoApiException() + mock_api.async_get_doors_status.side_effect = VolvoApiException() + mock_api.async_get_energy_capabilities.side_effect = VolvoApiException() + mock_api.async_get_energy_state.side_effect = VolvoApiException() + mock_api.async_get_engine_status.side_effect = VolvoApiException() + mock_api.async_get_engine_warnings.side_effect = VolvoApiException() + mock_api.async_get_fuel_status.side_effect = VolvoApiException() + mock_api.async_get_location.side_effect = VolvoApiException() + mock_api.async_get_odometer.side_effect = VolvoApiException() + mock_api.async_get_recharge_status.side_effect = VolvoApiException() + mock_api.async_get_statistics.side_effect = VolvoApiException() + mock_api.async_get_tyre_states.side_effect = VolvoApiException() + mock_api.async_get_warnings.side_effect = VolvoApiException() + mock_api.async_get_window_states.side_effect = VolvoApiException() + + return mock_api diff --git a/tests/components/volvo/test_init.py b/tests/components/volvo/test_init.py new file mode 100644 index 00000000000..e0e6c74b839 --- /dev/null +++ b/tests/components/volvo/test_init.py @@ -0,0 +1,125 @@ +"""Test Volvo init.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from unittest.mock import AsyncMock + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import TOKEN_URL +from volvocarsapi.models import VolvoAuthException + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from . import configure_mock +from .const import MOCK_ACCESS_TOKEN, SERVER_TOKEN_RESPONSE + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test setting up the integration.""" + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_token_refresh_success( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh succeeds.""" + + assert mock_config_entry.data[CONF_TOKEN]["access_token"] == MOCK_ACCESS_TOKEN + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify token + assert len(aioclient_mock.mock_calls) == 1 + assert ( + mock_config_entry.data[CONF_TOKEN]["access_token"] + == SERVER_TOKEN_RESPONSE["access_token"] + ) + + +@pytest.mark.parametrize( + ("token_response"), + [ + (HTTPStatus.FORBIDDEN), + (HTTPStatus.INTERNAL_SERVER_ERROR), + (HTTPStatus.NOT_FOUND), + ], +) +async def test_token_refresh_fail( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], + token_response: HTTPStatus, +) -> None: + """Test where token refresh fails.""" + + aioclient_mock.post(TOKEN_URL, status=token_response) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_token_refresh_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh indicates unauthorized.""" + + aioclient_mock.post(TOKEN_URL, status=HTTPStatus.UNAUTHORIZED) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert flows + assert flows[0]["handler"] == DOMAIN + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_no_vehicle( + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test no vehicle during coordinator setup.""" + mock_method: AsyncMock = mock_api.async_get_vehicle_details + + configure_mock(mock_method, return_value=None, side_effect=None) + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_vehicle_auth_failure( + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test auth failure during coordinator setup.""" + mock_method: AsyncMock = mock_api.async_get_vehicle_details + + configure_mock(mock_method, return_value=None, side_effect=VolvoAuthException()) + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py new file mode 100644 index 00000000000..a4b7a787117 --- /dev/null +++ b/tests/components/volvo/test_sensor.py @@ -0,0 +1,87 @@ +"""Test Volvo sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "full_model", + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + ], +) +async def test_sensor( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "full_model", + ["xc40_electric_2024"], +) +async def test_distance_to_empty_battery( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test using `distanceToEmptyBattery` instead of `electricRange`.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert hass.states.get("sensor.volvo_xc40_distance_to_empty_battery").state == "250" + + +@pytest.mark.parametrize( + ("full_model", "short_model"), + [("ex30_2024", "ex30"), ("xc60_phev_2020", "xc60")], +) +async def test_skip_invalid_api_fields( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + short_model: str, +) -> None: + """Test if invalid values are not creating a sensor.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert not hass.states.get(f"sensor.volvo_{short_model}_charging_current_limit") + + +@pytest.mark.parametrize( + "full_model", + ["ex30_2024"], +) +async def test_charging_power_value( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test if charging_power_value is zero if supported, but not charging.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert hass.states.get("sensor.volvo_ex30_charging_power").state == "0" diff --git a/tests/components/vulcan/fixtures/fake_student_1.json b/tests/components/vulcan/fixtures/fake_student_1.json index 0e6c79e4b03..fef69684550 100644 --- a/tests/components/vulcan/fixtures/fake_student_1.json +++ b/tests/components/vulcan/fixtures/fake_student_1.json @@ -25,5 +25,11 @@ "Surname": "Kowalski", "Sex": true }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } diff --git a/tests/components/vulcan/fixtures/fake_student_2.json b/tests/components/vulcan/fixtures/fake_student_2.json index 0176b72d4fc..e5200c12e17 100644 --- a/tests/components/vulcan/fixtures/fake_student_2.json +++ b/tests/components/vulcan/fixtures/fake_student_2.json @@ -25,5 +25,11 @@ "Surname": "Kowalska", "Sex": false }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 37e7d5059f0..35bf3cee242 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -1,387 +1 @@ """Tests for the Wallbox integration.""" - -from http import HTTPStatus - -import requests -import requests_mock - -from homeassistant.components.wallbox.const import ( - CHARGER_ADDED_ENERGY_KEY, - CHARGER_ADDED_RANGE_KEY, - CHARGER_CHARGING_POWER_KEY, - CHARGER_CHARGING_SPEED_KEY, - CHARGER_CURRENCY_KEY, - CHARGER_CURRENT_VERSION_KEY, - CHARGER_DATA_KEY, - CHARGER_ECO_SMART_KEY, - CHARGER_ECO_SMART_MODE_KEY, - CHARGER_ECO_SMART_STATUS_KEY, - CHARGER_ENERGY_PRICE_KEY, - CHARGER_FEATURES_KEY, - CHARGER_LOCKED_UNLOCKED_KEY, - CHARGER_MAX_AVAILABLE_POWER_KEY, - CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_MAX_ICP_CURRENT_KEY, - CHARGER_NAME_KEY, - CHARGER_PART_NUMBER_KEY, - CHARGER_PLAN_KEY, - CHARGER_POWER_BOOST_KEY, - CHARGER_SERIAL_NUMBER_KEY, - CHARGER_SOFTWARE_KEY, - CHARGER_STATUS_ID_KEY, -) -from homeassistant.core import HomeAssistant - -from .const import ERROR, REFRESH_TOKEN_TTL, STATUS, TTL, USER_ID - -from tests.common import MockConfigEntry - -test_response = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: False, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - -test_response_bidir = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: False, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - -test_response_eco_mode = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: True, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - - -test_response_full_solar = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: True, - CHARGER_ECO_SMART_MODE_KEY: 1, - }, - }, -} - -test_response_no_power_boost = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, - }, -} - - -http_404_error = requests.exceptions.HTTPError() -http_404_error.response = requests.Response() -http_404_error.response.status_code = HTTPStatus.NOT_FOUND -http_429_error = requests.exceptions.HTTPError() -http_429_error.response = requests.Response() -http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS - -authorisation_response = { - "data": { - "attributes": { - "token": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - REFRESH_TOKEN_TTL: 145756758, - ERROR: "false", - STATUS: 200, - } - } -} - - -authorisation_response_unauthorised = { - "data": { - "attributes": { - "token": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - REFRESH_TOKEN_TTL: 145756758, - ERROR: "false", - STATUS: 404, - } - } -} - -invalid_reauth_response = { - "jwt": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - "user_id": 12345, - "ttl": 145656758, - "refresh_token_ttl": 145756758, - "error": False, - "status": 200, -} - -http_403_error = requests.exceptions.HTTPError() -http_403_error.response = requests.Response() -http_403_error.response.status_code = HTTPStatus.FORBIDDEN - -http_404_error = requests.exceptions.HTTPError() -http_404_error.response = requests.Response() -http_404_error.response.status_code = HTTPStatus.NOT_FOUND - - -async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_no_eco_mode( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response_no_power_boost, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_select( - hass: HomeAssistant, entry: MockConfigEntry, response -) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response_bidir, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_connection_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup with a connection error.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.FORBIDDEN, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.FORBIDDEN, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.FORBIDDEN, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_read_only( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup for read only.""" - - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json=test_response, - status_code=HTTPStatus.FORBIDDEN, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup for read only.""" - - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json=test_response, - status_code=HTTPStatus.NOT_FOUND, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() diff --git a/tests/components/wallbox/conftest.py b/tests/components/wallbox/conftest.py index 72d493ceb69..c20c6e59da1 100644 --- a/tests/components/wallbox/conftest.py +++ b/tests/components/wallbox/conftest.py @@ -1,13 +1,38 @@ """Test fixtures for the Wallbox integration.""" -import pytest +from http import HTTPStatus +from unittest.mock import MagicMock, Mock, patch -from homeassistant.components.wallbox.const import CONF_STATION, DOMAIN +import pytest +import requests + +from homeassistant.components.wallbox.const import ( + CHARGER_DATA_POST_L1_KEY, + CHARGER_DATA_POST_L2_KEY, + CHARGER_ENERGY_PRICE_KEY, + CHARGER_LOCKED_UNLOCKED_KEY, + CHARGER_MAX_CHARGING_CURRENT_POST_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, + CONF_STATION, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from .const import WALLBOX_AUTHORISATION_RESPONSE, WALLBOX_STATUS_RESPONSE + from tests.common import MockConfigEntry +http_403_error = requests.exceptions.HTTPError() +http_403_error.response = requests.Response() +http_403_error.response.status_code = HTTPStatus.FORBIDDEN +http_404_error = requests.exceptions.HTTPError() +http_404_error.response = requests.Response() +http_404_error.response.status_code = HTTPStatus.NOT_FOUND +http_429_error = requests.exceptions.HTTPError() +http_429_error.response = requests.Response() +http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS + @pytest.fixture def entry(hass: HomeAssistant) -> MockConfigEntry: @@ -23,3 +48,46 @@ def entry(hass: HomeAssistant) -> MockConfigEntry: ) entry.add_to_hass(hass) return entry + + +@pytest.fixture +def mock_wallbox(): + """Patch Wallbox class for tests.""" + with patch("homeassistant.components.wallbox.Wallbox") as mock: + wallbox = MagicMock() + wallbox.authenticate = Mock(return_value=WALLBOX_AUTHORISATION_RESPONSE) + wallbox.lockCharger = Mock( + return_value={ + CHARGER_DATA_POST_L1_KEY: { + CHARGER_DATA_POST_L2_KEY: {CHARGER_LOCKED_UNLOCKED_KEY: True} + } + } + ) + wallbox.unlockCharger = Mock( + return_value={ + CHARGER_DATA_POST_L1_KEY: { + CHARGER_DATA_POST_L2_KEY: {CHARGER_LOCKED_UNLOCKED_KEY: True} + } + } + ) + wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 0.25}) + wallbox.setMaxChargingCurrent = Mock( + return_value={ + CHARGER_DATA_POST_L1_KEY: { + CHARGER_DATA_POST_L2_KEY: { + CHARGER_MAX_CHARGING_CURRENT_POST_KEY: True + } + } + } + ) + wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25}) + wallbox.getChargerStatus = Mock(return_value=WALLBOX_STATUS_RESPONSE) + mock.return_value = wallbox + yield wallbox + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test wallbox sensor class setup.""" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 82c9e5169d5..9650f9d3c61 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -1,5 +1,31 @@ """Provides constants for Wallbox component tests.""" +from homeassistant.components.wallbox.const import ( + CHARGER_ADDED_ENERGY_KEY, + CHARGER_ADDED_RANGE_KEY, + CHARGER_CHARGING_POWER_KEY, + CHARGER_CHARGING_SPEED_KEY, + CHARGER_CURRENCY_KEY, + CHARGER_CURRENT_VERSION_KEY, + CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_ECO_SMART_MODE_KEY, + CHARGER_ECO_SMART_STATUS_KEY, + CHARGER_ENERGY_PRICE_KEY, + CHARGER_FEATURES_KEY, + CHARGER_LOCKED_UNLOCKED_KEY, + CHARGER_MAX_AVAILABLE_POWER_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, + CHARGER_NAME_KEY, + CHARGER_PART_NUMBER_KEY, + CHARGER_PLAN_KEY, + CHARGER_POWER_BOOST_KEY, + CHARGER_SERIAL_NUMBER_KEY, + CHARGER_SOFTWARE_KEY, + CHARGER_STATUS_ID_KEY, +) + JWT = "jwt" USER_ID = "user_id" TTL = "ttl" @@ -7,6 +33,169 @@ REFRESH_TOKEN_TTL = "refresh_token_ttl" ERROR = "error" STATUS = "status" +WALLBOX_STATUS_RESPONSE = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + +WALLBOX_STATUS_RESPONSE_BIDIR = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + +WALLBOX_STATUS_RESPONSE_ECO_MODE = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + + +WALLBOX_STATUS_RESPONSE_FULL_SOLAR = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 1, + }, + }, +} + +WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, + }, +} + + +WALLBOX_AUTHORISATION_RESPONSE = { + "data": { + "attributes": { + "token": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + REFRESH_TOKEN_TTL: 145756758, + ERROR: "false", + STATUS: 200, + } + } +} + + +WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED = { + "data": { + "attributes": { + "token": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + REFRESH_TOKEN_TTL: 145756758, + ERROR: "false", + STATUS: 404, + } + } +} + +WALLBOX_INVALID_REAUTH_RESPONSE = { + "jwt": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + "user_id": 12345, + "ttl": 145656758, + "refresh_token_ttl": 145756758, + "error": False, + "status": 200, +} + + MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price" MOCK_NUMBER_ENTITY_ICP_CURRENT_ID = "number.wallbox_wallboxname_maximum_icp_current" diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index bdfb4cad18d..25265aeda4a 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch from homeassistant import config_entries -from homeassistant.components.wallbox import config_flow from homeassistant.components.wallbox.const import ( CHARGER_ADDED_ENERGY_KEY, CHARGER_ADDED_RANGE_KEY, @@ -18,12 +17,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ( - authorisation_response, - authorisation_response_unauthorised, - http_403_error, - http_404_error, - setup_integration, +from .conftest import http_403_error, http_404_error, setup_integration +from .const import ( + WALLBOX_AUTHORISATION_RESPONSE, + WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED, ) from tests.common import MockConfigEntry @@ -38,12 +35,11 @@ test_response = { } -async def test_show_set_form(hass: HomeAssistant) -> None: +async def test_show_set_form(hass: HomeAssistant, mock_wallbox) -> None: """Test that the setup form is served.""" - flow = config_flow.WallboxConfigFlow() - flow.hass = hass - result = await flow.async_step_user(user_input=None) - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -53,7 +49,6 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( patch( "homeassistant.components.wallbox.Wallbox.authenticate", @@ -73,8 +68,8 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -82,7 +77,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( patch( "homeassistant.components.wallbox.Wallbox.authenticate", @@ -102,8 +96,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} async def test_form_validate_input(hass: HomeAssistant) -> None: @@ -111,15 +105,14 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( patch( "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), + return_value=WALLBOX_AUTHORISATION_RESPONSE, ), patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response), + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + return_value=test_response, ), ): result2 = await hass.config_entries.flow.async_configure( @@ -135,20 +128,20 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: assert result2["data"]["station"] == "12345" -async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_form_reauth( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: """Test we handle reauth flow.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response_unauthorised), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response), + patch.object( + mock_wallbox, + "authenticate", + return_value=WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED, ), + patch.object(mock_wallbox, "getChargerStatus", return_value=test_response), ): result = await entry.start_reauth_flow(hass) @@ -161,27 +154,27 @@ async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" await hass.async_block_till_done() await hass.config_entries.async_unload(entry.entry_id) -async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_form_reauth_invalid( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: """Test we handle reauth invalid flow.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response_unauthorised), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response), + patch.object( + mock_wallbox, + "authenticate", + return_value=WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED, ), + patch.object(mock_wallbox, "getChargerStatus", return_value=test_response), ): result = await entry.start_reauth_flow(hass) diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 5048385aaf6..4d882da7a6e 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -1,27 +1,27 @@ """Test Wallbox Init Component.""" -from unittest.mock import Mock, patch +from datetime import datetime, timedelta +from unittest.mock import patch -from homeassistant.components.wallbox.const import DOMAIN +import pytest + +from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError -from . import ( - authorisation_response, - http_403_error, - http_429_error, - setup_integration, - setup_integration_connection_error, - setup_integration_no_eco_mode, - setup_integration_read_only, - test_response, +from .conftest import http_403_error, http_429_error, setup_integration +from .const import ( + MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_wallbox_setup_unload_entry( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox Unload.""" @@ -33,45 +33,36 @@ async def test_wallbox_setup_unload_entry( async def test_wallbox_unload_entry_connection_error( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox Unload Connection Error.""" + with patch.object(mock_wallbox, "authenticate", side_effect=http_403_error): + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.SETUP_ERROR - await setup_integration_connection_error(hass, entry) - assert entry.state is ConfigEntryState.SETUP_ERROR - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_connection_error_auth( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_refresh_failed_connection_error_too_many_requests( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with connection error.""" - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.LOADED + with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error): + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.SETUP_RETRY - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(side_effect=http_429_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(return_value=test_response), - ), - ): - wallbox = hass.data[DOMAIN][entry.entry_id] - - await wallbox.async_refresh() + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_invalid_auth( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_refresh_failed_error_auth( + hass: HomeAssistant, + entry: MockConfigEntry, + mock_wallbox, ) -> None: """Test Wallbox setup with authentication error.""" @@ -79,120 +70,94 @@ async def test_wallbox_refresh_failed_invalid_auth( assert entry.state is ConfigEntryState.LOADED with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(side_effect=http_403_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(side_effect=http_403_error), - ), + patch.object(mock_wallbox, "authenticate", side_effect=http_403_error), + pytest.raises(HomeAssistantError), ): - wallbox = hass.data[DOMAIN][entry.entry_id] + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) - await wallbox.async_refresh() + with ( + patch.object(mock_wallbox, "authenticate", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED async def test_wallbox_refresh_failed_http_error( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with authentication error.""" - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.LOADED - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(side_effect=http_403_error), - ), - ): - wallbox = hass.data[DOMAIN][entry.entry_id] - - await wallbox.async_refresh() + with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_403_error): + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED async def test_wallbox_refresh_failed_too_many_requests( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with authentication error.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(side_effect=http_429_error), - ), - ): - wallbox = hass.data[DOMAIN][entry.entry_id] - - await wallbox.async_refresh() + with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error): + async_fire_time_changed(hass, datetime.now() + timedelta(seconds=120), True) + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED async def test_wallbox_refresh_failed_connection_error( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with connection error.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(side_effect=http_403_error), - ), - ): - wallbox = hass.data[DOMAIN][entry.entry_id] - - await wallbox.async_refresh() - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED - - -async def test_wallbox_refresh_failed_read_only( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test Wallbox setup for read-only user.""" - - await setup_integration_read_only(hass, entry) - assert entry.state is ConfigEntryState.LOADED + with patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error): + async_fire_time_changed(hass, datetime.now() + timedelta(seconds=120), True) + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED async def test_wallbox_setup_load_entry_no_eco_mode( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox Unload.""" + with patch.object( + mock_wallbox, + "getChargerStatus", + return_value=WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, + ): + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED - await setup_integration_no_eco_mode(hass, entry) - assert entry.state is ConfigEntryState.LOADED - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 5842d708f11..3f856ed5dc2 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -1,28 +1,24 @@ """Test Wallbox Lock component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK -from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY +from homeassistant.components.wallbox.coordinator import InsufficientRights from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import ( - authorisation_response, - http_429_error, - setup_integration, - setup_integration_platform_not_ready, - setup_integration_read_only, -) +from .conftest import http_403_error, http_404_error, http_429_error, setup_integration from .const import MOCK_LOCK_ENTITY_ID from tests.common import MockConfigEntry -async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_wallbox_lock_class( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: """Test wallbox lock class.""" await setup_integration(hass, entry) @@ -31,56 +27,35 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - assert state assert state.state == "unlocked" - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), - ), - ): - await hass.services.async_call( - "lock", - SERVICE_LOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) - await hass.services.async_call( - "lock", - SERVICE_UNLOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) -async def test_wallbox_lock_class_connection_error( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_lock_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox lock class connection error.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=ConnectionError), - ), - pytest.raises(ConnectionError), + patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( "lock", @@ -92,42 +67,21 @@ async def test_wallbox_lock_class_connection_error( ) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=ConnectionError), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(side_effect=ConnectionError), - ), - pytest.raises(ConnectionError), + patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_404_error), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( "lock", - SERVICE_UNLOCK, + SERVICE_LOCK, { ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, }, blocking=True, ) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=http_429_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -139,26 +93,30 @@ async def test_wallbox_lock_class_connection_error( blocking=True, ) + with ( + patch.object(mock_wallbox, "lockCharger", side_effect=http_403_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_403_error), + pytest.raises(InsufficientRights), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) -async def test_wallbox_lock_class_authentication_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_read_only(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None - - -async def test_wallbox_lock_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None + with ( + patch.object(mock_wallbox, "lockCharger", side_effect=http_429_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c603ae24106..5c77189f264 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -1,135 +1,107 @@ """Test Wallbox Switch component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.wallbox.const import ( - CHARGER_ENERGY_PRICE_KEY, - CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_MAX_ICP_CURRENT_KEY, -) -from homeassistant.components.wallbox.coordinator import InvalidAuth +from homeassistant.components.wallbox.coordinator import InsufficientRights from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import ( - authorisation_response, - http_403_error, - http_404_error, - http_429_error, - setup_integration, - setup_integration_bidir, - setup_integration_platform_not_ready, -) +from .conftest import http_403_error, http_404_error, http_429_error, setup_integration from .const import ( MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, MOCK_NUMBER_ENTITY_ID, + WALLBOX_STATUS_RESPONSE_BIDIR, ) from tests.common import MockConfigEntry -mock_wallbox = Mock() -mock_wallbox.authenticate = Mock(return_value=authorisation_response) -mock_wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}) -mock_wallbox.setMaxChargingCurrent = Mock( - return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20} -) -mock_wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}) - -async def test_wallbox_number_class( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_number_power_class( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), - ), - ): - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - assert state.attributes["min"] == 6 - assert state.attributes["max"] == 25 + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == 6 + assert state.attributes["max"] == 25 - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, - ATTR_VALUE: 20, - }, - blocking=True, - ) + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, + ) -async def test_wallbox_number_class_bidir( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_number_power_class_bidir( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" + with patch.object( + mock_wallbox, "getChargerStatus", return_value=WALLBOX_STATUS_RESPONSE_BIDIR + ): + await setup_integration(hass, entry) - await setup_integration_bidir(hass, entry) - - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - assert state.attributes["min"] == -25 - assert state.attributes["max"] == 25 + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == -25 + assert state.attributes["max"] == 25 async def test_wallbox_number_energy_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + +async def test_wallbox_number_icp_power_class( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + +async def test_wallbox_number_power_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}), - ), - ): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_connection_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(side_effect=http_404_error), - ), + patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -142,23 +114,8 @@ async def test_wallbox_number_class_connection_error( blocking=True, ) - -async def test_wallbox_number_class_too_many_requests( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -171,23 +128,58 @@ async def test_wallbox_number_class_too_many_requests( blocking=True, ) + with ( + patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_403_error), + pytest.raises(InsufficientRights), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) -async def test_wallbox_number_class_energy_price_update_failed( - hass: HomeAssistant, entry: MockConfigEntry + +async def test_wallbox_number_energy_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "setEnergyCost", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "setEnergyCost", side_effect=http_404_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "setEnergyCost", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -201,92 +193,16 @@ async def test_wallbox_number_class_energy_price_update_failed( ) -async def test_wallbox_number_class_energy_price_update_connection_error( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_number_icp_power_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(side_effect=http_404_error), - ), - pytest.raises(HomeAssistantError), - ): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_energy_price_auth_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(side_effect=http_429_error), - ), - pytest.raises(HomeAssistantError), - ): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - - assert state is None - - -async def test_wallbox_number_class_icp_energy( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), - ), + patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_403_error), + pytest.raises(InsufficientRights), ): await hass.services.async_call( NUMBER_DOMAIN, @@ -298,52 +214,8 @@ async def test_wallbox_number_class_icp_energy( blocking=True, ) - -async def test_wallbox_number_class_icp_energy_auth_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(side_effect=http_403_error), - ), - pytest.raises(InvalidAuth), - ): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_icp_energy_connection_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(side_effect=http_404_error), - ), + patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -356,23 +228,8 @@ async def test_wallbox_number_class_icp_energy_connection_error( blocking=True, ) - -async def test_wallbox_number_class_icp_energy_too_many_request( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index f59a8367b41..c07d0ad5272 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -1,6 +1,6 @@ """Test Wallbox Select component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest @@ -9,58 +9,35 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY, EcoSmartMode +from homeassistant.components.wallbox.const import EcoSmartMode from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, HomeAssistantError -from . import ( - authorisation_response, - http_404_error, - http_429_error, - setup_integration_select, - test_response, - test_response_eco_mode, - test_response_full_solar, - test_response_no_power_boost, +from .conftest import http_404_error, http_429_error, setup_integration +from .const import ( + MOCK_SELECT_ENTITY_ID, + WALLBOX_STATUS_RESPONSE, + WALLBOX_STATUS_RESPONSE_ECO_MODE, + WALLBOX_STATUS_RESPONSE_FULL_SOLAR, + WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, ) -from .const import MOCK_SELECT_ENTITY_ID from tests.common import MockConfigEntry TEST_OPTIONS = [ - (EcoSmartMode.OFF, test_response), - (EcoSmartMode.ECO_MODE, test_response_eco_mode), - (EcoSmartMode.FULL_SOLAR, test_response_full_solar), + (EcoSmartMode.OFF, WALLBOX_STATUS_RESPONSE), + (EcoSmartMode.ECO_MODE, WALLBOX_STATUS_RESPONSE_ECO_MODE), + (EcoSmartMode.FULL_SOLAR, WALLBOX_STATUS_RESPONSE_FULL_SOLAR), ] -@pytest.fixture -def mock_authenticate(): - """Fixture to patch Wallbox methods.""" - with patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ): - yield - - @pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) async def test_wallbox_select_solar_charging_class( - hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_authenticate + hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_wallbox ) -> None: """Test wallbox select class.""" - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.enableEcoSmart", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - patch( - "homeassistant.components.wallbox.Wallbox.disableEcoSmart", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - ): - await setup_integration_select(hass, entry, response) + with patch.object(mock_wallbox, "getChargerStatus", return_value=response): + await setup_integration(hass, entry) await hass.services.async_call( SELECT_DOMAIN, @@ -77,39 +54,37 @@ async def test_wallbox_select_solar_charging_class( async def test_wallbox_select_no_power_boost_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox select class.""" - await setup_integration_select(hass, entry, test_response_no_power_boost) + with patch.object( + mock_wallbox, + "getChargerStatus", + return_value=WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, + ): + await setup_integration(hass, entry) - state = hass.states.get(MOCK_SELECT_ENTITY_ID) - assert state is None + state = hass.states.get(MOCK_SELECT_ENTITY_ID) + assert state is None @pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) -@pytest.mark.parametrize("error", [http_404_error, ConnectionError]) async def test_wallbox_select_class_error( hass: HomeAssistant, entry: MockConfigEntry, mode, response, - error, - mock_authenticate, + mock_wallbox, ) -> None: """Test wallbox select class connection error.""" - await setup_integration_select(hass, entry, response) + await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.disableEcoSmart", - new=Mock(side_effect=error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.enableEcoSmart", - new=Mock(side_effect=error), - ), + patch.object(mock_wallbox, "getChargerStatus", return_value=response), + patch.object(mock_wallbox, "disableEcoSmart", side_effect=http_404_error), + patch.object(mock_wallbox, "enableEcoSmart", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -129,21 +104,45 @@ async def test_wallbox_select_too_many_requests_error( entry: MockConfigEntry, mode, response, - mock_authenticate, + mock_wallbox, ) -> None: """Test wallbox select class connection error.""" - await setup_integration_select(hass, entry, response) + await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.disableEcoSmart", - new=Mock(side_effect=http_429_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.enableEcoSmart", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "getChargerStatus", return_value=response), + patch.object(mock_wallbox, "disableEcoSmart", side_effect=http_429_error), + patch.object(mock_wallbox, "enableEcoSmart", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +async def test_wallbox_select_connection_error( + hass: HomeAssistant, + entry: MockConfigEntry, + mode, + response, + mock_wallbox, +) -> None: + """Test wallbox select class connection error.""" + + await setup_integration(hass, entry) + + with ( + patch.object(mock_wallbox, "getChargerStatus", return_value=response), + patch.object(mock_wallbox, "disableEcoSmart", side_effect=ConnectionError), + patch.object(mock_wallbox, "enableEcoSmart", side_effect=ConnectionError), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_sensor.py b/tests/components/wallbox/test_sensor.py index 69d0cc57340..7373b5e70bb 100644 --- a/tests/components/wallbox/test_sensor.py +++ b/tests/components/wallbox/test_sensor.py @@ -3,7 +3,7 @@ from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant -from . import setup_integration +from .conftest import setup_integration from .const import ( MOCK_SENSOR_CHARGING_POWER_ID, MOCK_SENSOR_CHARGING_SPEED_ID, @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_wallbox_sensor_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index eb983ca44ce..189ce59f55c 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -1,23 +1,22 @@ """Test Wallbox Lock component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, http_404_error, http_429_error, setup_integration +from .conftest import http_404_error, http_429_error, setup_integration from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry async def test_wallbox_switch_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox switch class.""" @@ -27,55 +26,34 @@ async def test_wallbox_switch_class( assert state assert state.state == "on" - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - patch( - "homeassistant.components.wallbox.Wallbox.resumeChargingSession", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - ): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, + ) - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, + ) -async def test_wallbox_switch_class_connection_error( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_switch_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox switch class connection error.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.resumeChargingSession", - new=Mock(side_effect=http_404_error), - ), + patch.object(mock_wallbox, "resumeChargingSession", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): # Test behavior when a connection error occurs @@ -88,23 +66,8 @@ async def test_wallbox_switch_class_connection_error( blocking=True, ) - -async def test_wallbox_switch_class_too_many_requests( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox switch class connection error.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.resumeChargingSession", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "resumeChargingSession", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): # Test behavior when a connection error occurs diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py index b6f36680ee3..be808875df8 100644 --- a/tests/components/waqi/__init__.py +++ b/tests/components/waqi/__init__.py @@ -1 +1,13 @@ """Tests for the World Air Quality Index (WAQI) integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py index 75709d4f56e..bb64fdef097 100644 --- a/tests/components/waqi/conftest.py +++ b/tests/components/waqi/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the World Air Quality Index (WAQI) tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, patch +from aiowaqi import WAQIAirQuality import pytest from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -29,3 +31,28 @@ def mock_config_entry() -> MockConfigEntry: title="de Jongweg, Utrecht", data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584}, ) + + +@pytest.fixture +async def mock_waqi(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock WAQI client.""" + with ( + patch( + "homeassistant.components.waqi.WAQIClient", + autospec=True, + ) as mock_waqi, + patch( + "homeassistant.components.waqi.config_flow.WAQIClient", + new=mock_waqi, + ), + ): + client = mock_waqi.return_value + air_quality = WAQIAirQuality.from_dict( + await async_load_json_object_fixture( + hass, "air_quality_sensor.json", DOMAIN + ) + ) + client.get_by_station_number.return_value = air_quality + client.get_by_ip.return_value = air_quality + client.get_by_coordinates.return_value = air_quality + yield client diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index 08e58a74524..d0c46346b2e 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -1,5 +1,42 @@ # serializer version: 1 -# name: test_sensor +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_air_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -15,39 +52,104 @@ 'state': '29', }) # --- -# name: test_sensor.1 +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '4584_carbon_monoxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'device_class': 'humidity', - 'friendly_name': 'de Jongweg, Utrecht Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }) -# --- -# name: test_sensor.10 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', + 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', + 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '80', + 'state': '2.3', }) # --- -# name: test_sensor.11 +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'co', + 'no2', + 'o3', + 'so2', + 'pm10', + 'pm25', + 'neph', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_dominant_pollutant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dominant pollutant', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dominant_pollutant', + 'unique_id': '4584_dominant_pollutant', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -71,7 +173,309 @@ 'state': 'o3', }) # --- -# name: test_sensor.2 +# name: test_sensor[sensor.de_jongweg_utrecht_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'humidity', + 'friendly_name': 'de Jongweg, Utrecht Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nitrogen dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nitrogen_dioxide', + 'unique_id': '4584_nitrogen_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_ozone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ozone', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ozone', + 'unique_id': '4584_ozone', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Ozone', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_ozone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm10', + 'unique_id': '4584_pm10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM10', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': '4584_pm25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM2.5', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -88,7 +492,99 @@ 'state': '1008.8', }) # --- -# name: test_sensor.3 +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sulphur dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sulphur_dioxide', + 'unique_id': '4584_sulphur_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -105,93 +601,55 @@ 'state': '16', }) # --- -# name: test_sensor.4 +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Visibility using nephelometry', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'neph', + 'unique_id': '4584_neph', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', + 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', + 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.5 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.6 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Ozone', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_ozone', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.4', - }) -# --- -# name: test_sensor.7 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.8 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM10', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '12', - }) -# --- -# name: test_sensor.9 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM2.5', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', + 'state': '80', }) # --- diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index a3fa47abc67..03759f96ff5 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -1,15 +1,14 @@ """Test the World Air Quality Index (WAQI) config flow.""" -import json from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +from aiowaqi import WAQIAuthenticationError, WAQIConnectionError import pytest -from homeassistant import config_entries from homeassistant.components.waqi.config_flow import CONF_MAP from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -20,10 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import async_load_fixture - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - @pytest.mark.parametrize( ("method", "payload"), @@ -45,63 +40,28 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_full_map_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" @@ -109,6 +69,7 @@ async def test_full_map_flow( CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584, } + assert result["result"].unique_id == "4584" assert len(mock_setup_entry.mock_calls) == 1 @@ -121,73 +82,43 @@ async def test_full_map_flow( ], ) async def test_flow_errors( - hass: HomeAssistant, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test we handle errors during configuration.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - side_effect=exception, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "map" - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -232,6 +163,7 @@ async def test_flow_errors( async def test_error_in_second_step( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], exception: Exception, @@ -239,74 +171,36 @@ async def test_error_in_second_step( ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch("aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception), - patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = exception + mock_waqi.get_by_station_number.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = None + mock_waqi.get_by_station_number.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py new file mode 100644 index 00000000000..7e4487f8ad2 --- /dev/null +++ b/tests/components/waqi/test_init.py @@ -0,0 +1,24 @@ +"""Test the World Air Quality Index (WAQI) initialization.""" + +from unittest.mock import AsyncMock + +from aiowaqi import WAQIError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waqi: AsyncMock, +) -> None: + """Test setup failure due to API error.""" + mock_waqi.get_by_station_number.side_effect = WAQIError("API error") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 7cd045604c8..d6e14d2dd54 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -1,59 +1,27 @@ """Test the World Air Quality Index (WAQI) sensor.""" -import json -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.waqi.const import DOMAIN -from homeassistant.components.waqi.sensor import SENSORS -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_load_fixture +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_waqi: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - for sensor in SENSORS: - entity_id = entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, f"4584_{sensor.key}" - ) - assert hass.states.get(entity_id) == snapshot + """Test the World Air Quality Index (WAQI) sensor.""" + await setup_integration(hass, mock_config_entry) - -async def test_updating_failed( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - side_effect=WAQIError(), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index c9214ed8b71..fbaa7519ea8 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -53,7 +53,7 @@ def mock_update_fixture(): @pytest.fixture(name="validate_config_entry") def validate_config_entry_fixture(mock_update): """Return valid config entry.""" - mock_update.return_value = None + mock_update.return_value = [] return mock_update diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index 9ff7509a52c..da718a98983 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -116,8 +116,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: ["exclude"], - CONF_INCL_FILTER: ["include"], + CONF_EXCL_FILTER: ["ExcludeThis"], + CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -129,8 +129,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: ["exclude"], - CONF_INCL_FILTER: ["include"], + CONF_EXCL_FILTER: ["ExcludeThis"], + CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -140,8 +140,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: ["exclude"], - CONF_INCL_FILTER: ["include"], + CONF_EXCL_FILTER: ["ExcludeThis"], + CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py index 89bccc00985..d11bca524e9 100644 --- a/tests/components/waze_travel_time/test_init.py +++ b/tests/components/waze_travel_time/test_init.py @@ -101,8 +101,8 @@ async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, - CONF_INCL_FILTER: "include", - CONF_EXCL_FILTER: "exclude", + CONF_INCL_FILTER: "IncludeThis", + CONF_EXCL_FILTER: "ExcludeThis", }, ) @@ -114,5 +114,5 @@ async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: assert updated_entry.state is ConfigEntryState.LOADED assert updated_entry.version == 2 - assert updated_entry.options[CONF_INCL_FILTER] == ["include"] - assert updated_entry.options[CONF_EXCL_FILTER] == ["exclude"] + assert updated_entry.options[CONF_INCL_FILTER] == ["IncludeThis"] + assert updated_entry.options[CONF_EXCL_FILTER] == ["ExcludeThis"] diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index 94e3a0cf9d7..0aa99196c48 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.waze_travel_time.const import ( IMPERIAL_UNITS, METRIC_UNITS, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG @@ -153,5 +154,5 @@ async def test_sensor_failed_wrcerror( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("sensor.waze_travel_time").state == "unknown" + assert config_entry.state is ConfigEntryState.SETUP_RETRY assert "Error on retrieving data: " in caplog.text diff --git a/tests/components/weatherflow_cloud/conftest.py b/tests/components/weatherflow_cloud/conftest.py index 36b42bf24a8..0a2a0bff005 100644 --- a/tests/components/weatherflow_cloud/conftest.py +++ b/tests/components/weatherflow_cloud/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the WeatherflowCloud tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp import ClientResponseError import pytest +from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.models.rest.forecast import WeatherDataForecastREST from weatherflow4py.models.rest.observation import ObservationStationREST from weatherflow4py.models.rest.stations import StationsResponseREST from weatherflow4py.models.rest.unified import WeatherFlowDataREST +from weatherflow4py.ws import WeatherFlowWebsocketAPI from homeassistant.components.weatherflow_cloud.const import DOMAIN from homeassistant.const import CONF_API_TOKEN @@ -81,35 +83,88 @@ async def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_api(): - """Fixture for Mock WeatherFlowRestAPI.""" - get_stations_response_data = StationsResponseREST.from_json( - load_fixture("stations.json", DOMAIN) - ) - get_forecast_response_data = WeatherDataForecastREST.from_json( - load_fixture("forecast.json", DOMAIN) - ) - get_observation_response_data = ObservationStationREST.from_json( - load_fixture("station_observation.json", DOMAIN) - ) +def mock_rest_api(): + """Mock rest api.""" + fixtures = { + "stations": StationsResponseREST.from_json( + load_fixture("stations.json", DOMAIN) + ), + "forecast": WeatherDataForecastREST.from_json( + load_fixture("forecast.json", DOMAIN) + ), + "observation": ObservationStationREST.from_json( + load_fixture("station_observation.json", DOMAIN) + ), + } + # Create device_station_map + device_station_map = { + device.device_id: station.station_id + for station in fixtures["stations"].stations + for device in station.devices + } + + # Prepare mock data data = { 24432: WeatherFlowDataREST( - weather=get_forecast_response_data, - observation=get_observation_response_data, - station=get_stations_response_data.stations[0], + weather=fixtures["forecast"], + observation=fixtures["observation"], + station=fixtures["stations"].stations[0], device_observations=None, ) } - with patch( - "homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowRestAPI", - autospec=True, - ) as mock_api_class: - # Create an instance of AsyncMock for the API - mock_api = AsyncMock() - mock_api.get_all_data.return_value = data - # Patch the class to return our mock_api instance - mock_api_class.return_value = mock_api + mock_api = AsyncMock(spec=WeatherFlowRestAPI) + mock_api.get_all_data.return_value = data + mock_api.async_get_stations.return_value = fixtures["stations"] + mock_api.device_station_map = device_station_map + mock_api.api_token = MOCK_API_TOKEN + # Apply patches + with ( + patch( + "homeassistant.components.weatherflow_cloud.WeatherFlowRestAPI", + return_value=mock_api, + ) as _, + patch( + "homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowRestAPI", + return_value=mock_api, + ) as _, + ): yield mock_api + + +@pytest.fixture +def mock_stations_data(mock_rest_api): + """Mock stations data for coordinator tests.""" + return mock_rest_api.async_get_stations.return_value + + +@pytest.fixture +async def mock_websocket_api(): + """Mock WeatherFlowWebsocketAPI.""" + mock_websocket = AsyncMock() + mock_websocket.send = AsyncMock() + mock_websocket.recv = AsyncMock() + + mock_ws_instance = AsyncMock(spec=WeatherFlowWebsocketAPI) + mock_ws_instance.connect = AsyncMock() + mock_ws_instance.send_message = AsyncMock() + mock_ws_instance.register_callback = MagicMock() + mock_ws_instance.websocket = mock_websocket + + with ( + patch( + "homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowWebsocketAPI", + return_value=mock_ws_instance, + ), + patch( + "homeassistant.components.weatherflow_cloud.WeatherFlowWebsocketAPI", + return_value=mock_ws_instance, + ), + patch( + "weatherflow4py.ws.WeatherFlowWebsocketAPI", return_value=mock_ws_instance + ), + ): + # mock_connect.return_value = mock_websocket + yield mock_ws_instance diff --git a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr index f9819f39dca..cd6280077a2 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr @@ -42,7 +42,7 @@ # name: test_all_entities[sensor.my_home_station_air_density-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Air density', 'state_class': , 'unit_of_measurement': 'kg/m³', @@ -98,7 +98,7 @@ # name: test_all_entities[sensor.my_home_station_dew_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Dew point', 'state_class': , @@ -155,7 +155,7 @@ # name: test_all_entities[sensor.my_home_station_feels_like-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Feels like', 'state_class': , @@ -212,7 +212,7 @@ # name: test_all_entities[sensor.my_home_station_heat_index-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Heat index', 'state_class': , @@ -266,7 +266,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Lightning count', 'state_class': , }), @@ -318,7 +318,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_count_last_1_hr-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Lightning count last 1 hr', 'state_class': , }), @@ -370,7 +370,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_count_last_3_hr-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Lightning count last 3 hr', 'state_class': , }), @@ -425,7 +425,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_last_distance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'distance', 'friendly_name': 'My Home Station Lightning last distance', 'state_class': , @@ -477,7 +477,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_last_strike-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'timestamp', 'friendly_name': 'My Home Station Lightning last strike', }), @@ -489,6 +489,466 @@ 'state': '2024-02-07T23:01:15+00:00', }) # --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_duration_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_duration_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nearcast precipitation duration yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_minutes_local_yesterday_final', + 'unique_id': '24432_precip_minutes_local_yesterday_final', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_duration_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Nearcast precipitation duration yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_duration_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nearcast precipitation today', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_day_final', + 'unique_id': '24432_precip_accum_local_day_final', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Nearcast precipitation today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nearcast precipitation yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_yesterday_final', + 'unique_id': '24432_precip_accum_local_yesterday_final', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Nearcast precipitation yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_precipitation_duration_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation duration today', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_minutes_local_day', + 'unique_id': '24432_precip_minutes_local_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation duration today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_duration_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_precipitation_duration_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation duration yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_minutes_local_yesterday', + 'unique_id': '24432_precip_minutes_local_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation duration yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_duration_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_precipitation_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation today', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_day', + 'unique_id': '24432_precip_accum_local_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_type_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rain', + 'snow', + 'sleet', + 'storm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_precipitation_type_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation type yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_analysis_type_yesterday', + 'unique_id': '24432_precip_analysis_type_yesterday', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_type_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'enum', + 'friendly_name': 'My Home Station Precipitation type yesterday', + 'options': list([ + 'none', + 'rain', + 'snow', + 'sleet', + 'storm', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_type_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_precipitation_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_yesterday', + 'unique_id': '24432_precip_accum_local_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.my_home_station_pressure_barometric-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -535,7 +995,7 @@ # name: test_all_entities[sensor.my_home_station_pressure_barometric-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'atmospheric_pressure', 'friendly_name': 'My Home Station Pressure barometric', 'state_class': , @@ -595,7 +1055,7 @@ # name: test_all_entities[sensor.my_home_station_pressure_sea_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'atmospheric_pressure', 'friendly_name': 'My Home Station Pressure sea level', 'state_class': , @@ -609,6 +1069,62 @@ 'state': '1006.2', }) # --- +# name: test_all_entities[sensor.my_home_station_rain_last_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_last_1hr', + 'unique_id': '24432_precip_accum_last_1hr', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_rain_last_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Rain last hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_rain_last_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.my_home_station_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -652,7 +1168,7 @@ # name: test_all_entities[sensor.my_home_station_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Temperature', 'state_class': , @@ -709,7 +1225,7 @@ # name: test_all_entities[sensor.my_home_station_wet_bulb_globe_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Wet bulb globe temperature', 'state_class': , @@ -766,7 +1282,7 @@ # name: test_all_entities[sensor.my_home_station_wet_bulb_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Wet bulb temperature', 'state_class': , @@ -823,7 +1339,7 @@ # name: test_all_entities[sensor.my_home_station_wind_chill-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Wind chill', 'state_class': , @@ -837,3 +1353,350 @@ 'state': '10.5', }) # --- +# name: test_all_entities[sensor.my_home_station_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': '24432_123456_wind_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_direction', + 'friendly_name': 'My Home Station Wind direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust', + 'unique_id': '24432_123456_wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_lull-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_wind_lull', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind lull', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_lull', + 'unique_id': '24432_123456_wind_lull', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_lull-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind lull', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_lull', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_sample_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_home_station_wind_sample_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind sample interval', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_sample_interval', + 'unique_id': '24432_123456_wind_sample_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_sample_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Wind sample interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_sample_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '24432_123456_wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_speed_avg-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_wind_speed_avg', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed (avg)', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_avg', + 'unique_id': '24432_123456_wind_avg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_speed_avg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind speed (avg)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_speed_avg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr index 867f7874ed3..895333bf269 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr @@ -37,7 +37,7 @@ # name: test_weather[weather.my_home_station-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'dew_point': -13.0, 'friendly_name': 'My Home Station', 'humidity': 27, diff --git a/tests/components/weatherflow_cloud/test_coordinators.py b/tests/components/weatherflow_cloud/test_coordinators.py new file mode 100644 index 00000000000..bb38cfacac8 --- /dev/null +++ b/tests/components/weatherflow_cloud/test_coordinators.py @@ -0,0 +1,223 @@ +"""Tests for the WeatherFlow Cloud coordinators.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientResponseError +import pytest +from weatherflow4py.models.ws.types import EventType +from weatherflow4py.models.ws.websocket_request import ( + ListenStartMessage, + RapidWindListenStartMessage, +) +from weatherflow4py.models.ws.websocket_response import ( + EventDataRapidWind, + ObservationTempestWS, + RapidWindWS, +) + +from homeassistant.components.weatherflow_cloud.coordinator import ( + WeatherFlowCloudUpdateCoordinatorREST, + WeatherFlowObservationCoordinator, + WeatherFlowWindCoordinator, +) +from homeassistant.config_entries import ConfigEntryAuthFailed +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import MockConfigEntry + + +async def test_wind_coordinator_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test wind coordinator setup.""" + + coordinator = WeatherFlowWindCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + await coordinator.async_setup() + + # Verify websocket setup + mock_websocket_api.connect.assert_called_once() + mock_websocket_api.register_callback.assert_called_once_with( + message_type=EventType.RAPID_WIND, + callback=coordinator._handle_websocket_message, + ) + # In the refactored code, send_message is called for each device ID + assert mock_websocket_api.send_message.called + + # Verify at least one message is of the correct type + call_args_list = mock_websocket_api.send_message.call_args_list + assert any( + isinstance(call.args[0], RapidWindListenStartMessage) for call in call_args_list + ) + + +async def test_observation_coordinator_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test observation coordinator setup.""" + + coordinator = WeatherFlowObservationCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + await coordinator.async_setup() + + # Verify websocket setup + mock_websocket_api.connect.assert_called_once() + mock_websocket_api.register_callback.assert_called_once_with( + message_type=EventType.OBSERVATION, + callback=coordinator._handle_websocket_message, + ) + # In the refactored code, send_message is called for each device ID + assert mock_websocket_api.send_message.called + + # Verify at least one message is of the correct type + call_args_list = mock_websocket_api.send_message.call_args_list + assert any(isinstance(call.args[0], ListenStartMessage) for call in call_args_list) + + +async def test_wind_coordinator_message_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test wind coordinator message handling.""" + + coordinator = WeatherFlowWindCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + # Create mock wind data + mock_wind_data = Mock(spec=EventDataRapidWind) + mock_message = Mock(spec=RapidWindWS) + + # Use a device ID from the actual mock data + # The first device from the first station in the mock data + device_id = mock_stations_data.stations[0].devices[0].device_id + station_id = mock_stations_data.stations[0].station_id + + mock_message.device_id = device_id + mock_message.ob = mock_wind_data + + # Handle the message + await coordinator._handle_websocket_message(mock_message) + + # Verify data was stored correctly + assert coordinator._ws_data[station_id][device_id] == mock_wind_data + + +async def test_observation_coordinator_message_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test observation coordinator message handling.""" + + coordinator = WeatherFlowObservationCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + # Create mock observation data + mock_message = Mock(spec=ObservationTempestWS) + + # Use a device ID from the actual mock data + # The first device from the first station in the mock data + device_id = mock_stations_data.stations[0].devices[0].device_id + station_id = mock_stations_data.stations[0].station_id + + mock_message.device_id = device_id + + # Handle the message + await coordinator._handle_websocket_message(mock_message) + + # Verify data was stored correctly (for observations, the message IS the data) + assert coordinator._ws_data[station_id][device_id] == mock_message + + +async def test_rest_coordinator_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test REST coordinator handling of 401 auth error.""" + # Create the coordinator + coordinator = WeatherFlowCloudUpdateCoordinatorREST( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + stations=mock_stations_data, + ) + + # Mock a 401 auth error + mock_rest_api.get_all_data.side_effect = ClientResponseError( + request_info=Mock(), + history=Mock(), + status=401, + message="Unauthorized", + ) + + # Verify the error is properly converted to ConfigEntryAuthFailed + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_update_data() + + +async def test_rest_coordinator_other_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test REST coordinator handling of non-auth errors.""" + # Create the coordinator + coordinator = WeatherFlowCloudUpdateCoordinatorREST( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + stations=mock_stations_data, + ) + + # Mock a 500 server error + mock_rest_api.get_all_data.side_effect = ClientResponseError( + request_info=Mock(), + history=Mock(), + status=500, + message="Internal Server Error", + ) + + # Verify the error is properly converted to UpdateFailed + with pytest.raises( + UpdateFailed, match="Update failed: 500, message='Internal Server Error'" + ): + await coordinator._async_update_data() diff --git a/tests/components/weatherflow_cloud/test_sensor.py b/tests/components/weatherflow_cloud/test_sensor.py index 59374a80a4b..dce2b7f8f2e 100644 --- a/tests/components/weatherflow_cloud/test_sensor.py +++ b/tests/components/weatherflow_cloud/test_sensor.py @@ -1,13 +1,22 @@ """Tests for the WeatherFlow Cloud sensor platform.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from weatherflow4py.models.rest.observation import ObservationStationREST from homeassistant.components.weatherflow_cloud import DOMAIN +from homeassistant.components.weatherflow_cloud.coordinator import ( + WeatherFlowObservationCoordinator, + WeatherFlowWindCoordinator, +) +from homeassistant.components.weatherflow_cloud.sensor import ( + WeatherFlowWebsocketSensorObservation, + WeatherFlowWebsocketSensorWind, +) from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,12 +31,14 @@ from tests.common import ( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_api: AsyncMock, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, ) -> None: """Test all entities.""" with patch( @@ -38,11 +49,13 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities_with_lightning_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_api: AsyncMock, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: """Test all entities.""" @@ -62,9 +75,9 @@ async def test_all_entities_with_lightning_error( ) # Update the data in our API - all_data = await mock_api.get_all_data() + all_data = await mock_rest_api.get_all_data() all_data[24432].observation = get_observation_response_data - mock_api.get_all_data.return_value = all_data + mock_rest_api.get_all_data.return_value = all_data # Move time forward freezer.tick(timedelta(minutes=5)) @@ -75,3 +88,92 @@ async def test_all_entities_with_lightning_error( hass.states.get("sensor.my_home_station_lightning_last_strike").state == STATE_UNKNOWN ) + + +async def test_websocket_sensor_observation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, +) -> None: + """Test the WebsocketSensorObservation class works.""" + # Set up the integration + with patch( + "homeassistant.components.weatherflow_cloud.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + # Create a mock coordinator with test data + coordinator = MagicMock(spec=WeatherFlowObservationCoordinator) + + # Mock the coordinator data structure + test_station_id = 24432 + test_device_id = 12345 + test_data = { + "temperature": 22.5, + "humidity": 45, + "pressure": 1013.2, + } + + coordinator.data = {test_station_id: {test_device_id: test_data}} + + # Create a sensor entity description + entity_description = MagicMock() + entity_description.value_fn = lambda data: data["temperature"] + + # Create the sensor + sensor = WeatherFlowWebsocketSensorObservation( + coordinator=coordinator, + description=entity_description, + station_id=test_station_id, + device_id=test_device_id, + ) + + # Test that native_value returns the correct value + assert sensor.native_value == 22.5 + + +async def test_websocket_sensor_wind( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, +) -> None: + """Test the WebsocketSensorWind class works.""" + # Set up the integration + with patch( + "homeassistant.components.weatherflow_cloud.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + # Create a mock coordinator with test data + coordinator = MagicMock(spec=WeatherFlowWindCoordinator) + + # Mock the coordinator data structure + test_station_id = 24432 + test_device_id = 12345 + test_data = { + "wind_speed": 5.2, + "wind_direction": 180, + } + + coordinator.data = {test_station_id: {test_device_id: test_data}} + + # Create a sensor entity description + entity_description = MagicMock() + entity_description.value_fn = lambda data: data["wind_speed"] + + # Create the sensor + sensor = WeatherFlowWebsocketSensorWind( + coordinator=coordinator, + description=entity_description, + station_id=test_station_id, + device_id=test_device_id, + ) + + # Test that native_value returns the correct value + assert sensor.native_value == 5.2 + + # Test with None data (startup condition) + coordinator.data = None + assert sensor.native_value is None diff --git a/tests/components/weatherflow_cloud/test_weather.py b/tests/components/weatherflow_cloud/test_weather.py index 8da67b27060..029cbb11a6e 100644 --- a/tests/components/weatherflow_cloud/test_weather.py +++ b/tests/components/weatherflow_cloud/test_weather.py @@ -18,7 +18,9 @@ async def test_weather( snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_api: AsyncMock, + mock_rest_api: AsyncMock, + mock_get_stations: AsyncMock, + mock_websocket_api: AsyncMock, ) -> None: """Test all entities.""" with patch( diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index 65badabe593..9659724e8a9 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -13,7 +13,6 @@ from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup from homeassistant.components.webdav.backup import async_register_backup_agents_listener from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .const import BACKUP_METADATA @@ -31,7 +30,6 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index 9204e6eadab..3ee5c8ae9ad 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import pytest from homeassistant import config_entries @@ -86,6 +90,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (AccessDeniedError("https://webdav.demo"), "access_denied"), (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py index 124a644fa93..89f0e703b22 100644 --- a/tests/components/webdav/test_init.py +++ b/tests/components/webdav/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import WebDavError +from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError, WebDavError import pytest from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN @@ -110,3 +110,47 @@ async def test_migrate_error( 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' in caplog.text ) + + +@pytest.mark.parametrize( + ("error", "expected_message", "expected_state"), + [ + ( + UnauthorizedError("Unauthorized"), + "Invalid username or password", + ConfigEntryState.SETUP_ERROR, + ), + ( + AccessDeniedError("/access_denied"), + "Access denied to /access_denied", + ConfigEntryState.SETUP_ERROR, + ), + ], + ids=["UnauthorizedError", "AccessDeniedError"], +) +async def test_error_during_setup( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, + error: Exception, + expected_message: str, + expected_state: ConfigEntryState, +) -> None: + """Test handling of various errors during setup.""" + webdav_client.check.side_effect = error + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert expected_message in caplog.text + assert config_entry.state is expected_state diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 9c097b166ec..7c0bdfb0d13 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -53,7 +53,6 @@ 'some-fake-uuid', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'LG', @@ -63,7 +62,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': 'major.minor', 'via_device_id': None, }) diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 564ff9afa9b..2445140aff4 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -4,7 +4,12 @@ from aiowebostv import WebOsTvPairError import pytest from homeassistant import config_entries -from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID +from homeassistant.components.webostv.const import ( + CONF_SOURCES, + DEFAULT_NAME, + DOMAIN, + LIVE_TV_APP_ID, +) from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant @@ -63,6 +68,29 @@ async def test_form(hass: HomeAssistant, client) -> None: assert config_entry.unique_id == FAKE_UUID +async def test_form_no_model_name(hass: HomeAssistant, client) -> None: + """Test successful user flow without model name.""" + client.tv_info.system = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_USER_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID + + @pytest.mark.parametrize( ("apps", "inputs"), [ diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index cd8f443c8fd..d7fb12c2848 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -54,6 +54,7 @@ async def test_update_options(hass: HomeAssistant, client) -> None: new_options = config_entry.options.copy() new_options[CONF_SOURCES] = ["Input02", "Live TV"] hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index c7decafff73..646b8f8034a 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -182,4 +182,4 @@ async def test_trigger_invalid_entity_id( }, ) - assert f"Entity {invalid_entity} is not a valid {DOMAIN} entity" in caplog.text + assert f"Entity {invalid_entity} is not a valid webOS TV entity" in caplog.text diff --git a/tests/components/websocket_api/snapshots/test_commands.ambr b/tests/components/websocket_api/snapshots/test_commands.ambr new file mode 100644 index 00000000000..e8ac80e0e24 --- /dev/null +++ b/tests/components/websocket_api/snapshots/test_commands.ambr @@ -0,0 +1,136 @@ +# serializer version: 1 +# name: test_get_services + dict({ + 'reload': dict({ + 'description': 'Reloads group configuration, entities, and notify services from YAML-configuration.', + 'fields': dict({ + }), + 'name': 'Reload', + }), + 'remove': dict({ + 'description': 'Removes a group.', + 'fields': dict({ + 'object_id': dict({ + 'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].', + 'example': 'test_group', + 'name': 'Object ID', + 'required': True, + 'selector': dict({ + 'object': dict({ + }), + }), + }), + }), + 'name': 'Remove', + }), + 'set': dict({ + 'description': 'Creates/Updates a group.', + 'fields': dict({ + 'add_entities': dict({ + 'description': 'List of members to be added to the group. Cannot be used in combination with `Entities` or `Remove entities`.', + 'example': 'domain.entity_id1, domain.entity_id2', + 'name': 'Add entities', + 'selector': dict({ + 'entity': dict({ + 'multiple': True, + 'reorder': False, + }), + }), + }), + 'all': dict({ + 'description': 'Enable this option if the group should only be used when all entities are in state `on`.', + 'name': 'All', + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'entities': dict({ + 'description': 'List of all members in the group. Cannot be used in combination with `Add entities` or `Remove entities`.', + 'example': 'domain.entity_id1, domain.entity_id2', + 'name': 'Entities', + 'selector': dict({ + 'entity': dict({ + 'multiple': True, + 'reorder': False, + }), + }), + }), + 'icon': dict({ + 'description': 'Name of the icon for the group.', + 'example': 'mdi:camera', + 'name': 'Icon', + 'selector': dict({ + 'icon': dict({ + }), + }), + }), + 'name': dict({ + 'description': 'Name of the group.', + 'example': 'My test group', + 'name': 'Name', + 'selector': dict({ + 'text': dict({ + }), + }), + }), + 'object_id': dict({ + 'description': 'Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id].', + 'example': 'test_group', + 'name': 'Object ID', + 'required': True, + 'selector': dict({ + 'text': dict({ + }), + }), + }), + 'remove_entities': dict({ + 'description': 'List of members to be removed from a group. Cannot be used in combination with `Entities` or `Add entities`.', + 'example': 'domain.entity_id1, domain.entity_id2', + 'name': 'Remove entities', + 'selector': dict({ + 'entity': dict({ + 'multiple': True, + 'reorder': False, + }), + }), + }), + }), + 'name': 'Set', + }), + }) +# --- +# name: test_get_services.1 + dict({ + 'set_default_level': dict({ + 'description': 'Translated description', + 'fields': dict({ + 'level': dict({ + 'description': 'Field description', + 'example': 'Field example', + 'name': 'Field name', + 'selector': dict({ + 'select': dict({ + 'options': list([ + 'debug', + 'info', + 'warning', + 'error', + 'fatal', + 'critical', + ]), + 'translation_key': 'level', + }), + }), + }), + }), + 'name': 'Translated name', + }), + 'set_level': dict({ + 'description': '', + 'fields': dict({ + }), + 'name': '', + }), + }) +# --- diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 6e4fa34ed26..bffb2959b31 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2,15 +2,19 @@ import asyncio from copy import deepcopy +import io import logging from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant import loader from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.group import DOMAIN as DOMAIN_GROUP +from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER from homeassistant.components.websocket_api import const from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, @@ -18,7 +22,9 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH_REQUIRED, ) from homeassistant.components.websocket_api.commands import ( + ALL_CONDITION_DESCRIPTIONS_JSON_CACHE, ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, + ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE, ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.config_entries import ConfigEntryState @@ -28,9 +34,10 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.loader import async_get_integration +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util.json import json_loads +from homeassistant.util.yaml.loader import JSON_TYPE, parse_yaml from tests.common import ( MockConfigEntry, @@ -667,7 +674,9 @@ async def test_get_states( async def test_get_services( - hass: HomeAssistant, websocket_client: MockHAClientWebSocket + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + snapshot: SnapshotAssertion, ) -> None: """Test get_services command.""" assert ALL_SERVICE_DESCRIPTIONS_JSON_CACHE not in hass.data @@ -682,16 +691,18 @@ async def test_get_services( assert msg == {"id": 2, "result": {}, "success": True, "type": "result"} assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache - # Load a service and check cache is updated - assert await async_setup_component(hass, "logger", {}) + # Set up an integration that has services and check cache is updated + assert await async_setup_component(hass, DOMAIN_GROUP, {DOMAIN_GROUP: {}}) await websocket_client.send_json_auto_id({"type": "get_services"}) msg = await websocket_client.receive_json() assert msg == { "id": 3, - "result": {"logger": {"set_default_level": ANY, "set_level": ANY}}, + "result": {DOMAIN_GROUP: ANY}, "success": True, "type": "result", } + group_services = msg["result"][DOMAIN_GROUP] + assert group_services == snapshot assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is not old_cache # Check cache is reused @@ -700,12 +711,240 @@ async def test_get_services( msg = await websocket_client.receive_json() assert msg == { "id": 4, - "result": {"logger": {"set_default_level": ANY, "set_level": ANY}}, + "result": {DOMAIN_GROUP: group_services}, "success": True, "type": "result", } assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache + # Set up an integration with legacy translations in services.yaml + def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: + return { + "set_default_level": { + "description": "Translated description", + "fields": { + "level": { + "description": "Field description", + "example": "Field example", + "name": "Field name", + "selector": { + "select": { + "options": [ + "debug", + "info", + "warning", + "error", + "fatal", + "critical", + ], + "translation_key": "level", + } + }, + } + }, + "name": "Translated name", + }, + "set_level": None, + } + + await async_setup_component(hass, DOMAIN_LOGGER, {DOMAIN_LOGGER: {}}) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.helpers.service._load_services_file", + side_effect=_load_services_file, + ), + patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={}, + ), + ): + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + + assert msg == { + "id": 5, + "result": { + DOMAIN_LOGGER: ANY, + DOMAIN_GROUP: group_services, + }, + "success": True, + "type": "result", + } + logger_services = msg["result"][DOMAIN_LOGGER] + assert logger_services == snapshot + + +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_conditions", return_value=True) +async def test_subscribe_conditions( + mock_has_conditions: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test condition_platforms/subscribe command.""" + sun_condition_descriptions = """ + _: {} + """ + device_automation_condition_descriptions = """ + _device: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("device_automation/conditions.yaml"): + condition_descriptions = device_automation_condition_descriptions + elif fname.endswith("sun/conditions.yaml"): + condition_descriptions = sun_condition_descriptions + else: + raise FileNotFoundError + with io.StringIO(condition_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + assert await async_setup_component(hass, "sun", {}) + assert await async_setup_component(hass, "system_health", {}) + await hass.async_block_till_done() + + assert ALL_CONDITION_DESCRIPTIONS_JSON_CACHE not in hass.data + + await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"}) + + # Test start subscription with initial event + msg = await websocket_client.receive_json() + assert msg == {"id": 1, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"} + + old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] + + # Test we receive an event when a new platform is loaded, if it has descriptions + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "device_automation", {}) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg == { + "event": {"device": {"fields": {}}}, + "id": 1, + "type": "event", + } + + # Initiate a second subscription to check the cache is updated because of the new + # condition + await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 2, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"device": {"fields": {}}, "sun": {"fields": {}}}, + "id": 2, + "type": "event", + } + + assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is not old_cache + + # Initiate a third subscription to check the cache is not updated because no new + # condition was added + old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 3, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"device": {"fields": {}}, "sun": {"fields": {}}}, + "id": 3, + "type": "event", + } + + assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is old_cache + + +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_triggers", return_value=True) +async def test_subscribe_triggers( + mock_has_triggers: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test trigger_platforms/subscribe command.""" + sun_trigger_descriptions = """ + _: {} + """ + tag_trigger_descriptions = """ + _: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + elif fname.endswith("tag/triggers.yaml"): + trigger_descriptions = tag_trigger_descriptions + else: + raise FileNotFoundError + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + assert await async_setup_component(hass, "sun", {}) + assert await async_setup_component(hass, "system_health", {}) + await hass.async_block_till_done() + + assert ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE not in hass.data + + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + + # Test start subscription with initial event + msg = await websocket_client.receive_json() + assert msg == {"id": 1, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"} + + old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] + + # Test we receive an event when a new platform is loaded, if it has descriptions + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "tag", {}) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg == { + "event": {"tag": {"fields": {}}}, + "id": 1, + "type": "event", + } + + # Initiate a second subscription to check the cache is updated because of the new + # trigger + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 2, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"sun": {"fields": {}}, "tag": {"fields": {}}}, + "id": 2, + "type": "event", + } + + assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is not old_cache + + # Initiate a third subscription to check the cache is not updated because no new + # trigger was added + old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 3, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"sun": {"fields": {}}, "tag": {"fields": {}}}, + "id": 3, + "type": "event", + } + + assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is old_cache + async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket @@ -2564,10 +2803,7 @@ async def test_validate_config_works( "entity_id": "hello.world", "state": "paulus", }, - ( - "Invalid condition \"non_existing\" specified {'condition': " - "'non_existing', 'entity_id': 'hello.world', 'state': 'paulus'}" - ), + 'Invalid condition "non_existing" specified', ), # Raises HomeAssistantError ( diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 7447c1edd5a..fb82750924a 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -4,7 +4,7 @@ from unittest import mock from unittest.mock import Mock import pytest -from whirlpool import aircon, appliancesmanager, auth, washerdryer +from whirlpool import aircon, appliancesmanager, auth, dryer, washer from whirlpool.backendselector import Brand, Region from .const import MOCK_SAID1, MOCK_SAID2 @@ -66,10 +66,8 @@ def fixture_mock_appliances_manager_api( mock_aircon1_api, mock_aircon2_api, ] - mock_appliances_manager.return_value.washer_dryers = [ - mock_washer_api, - mock_dryer_api, - ] + mock_appliances_manager.return_value.washers = [mock_washer_api] + mock_appliances_manager.return_value.dryers = [mock_dryer_api] yield mock_appliances_manager @@ -123,15 +121,13 @@ def fixture_mock_aircon2_api(): @pytest.fixture def mock_washer_api(): """Get a mock of a washer.""" - mock_washer = Mock(spec=washerdryer.WasherDryer, said="said_washer") + mock_washer = Mock(spec=washer.Washer, said="said_washer") mock_washer.name = "Washer" mock_washer.appliance_info = Mock( data_model="washer", category="washer_dryer", model_number="12345" ) mock_washer.get_online.return_value = True - mock_washer.get_machine_state.return_value = ( - washerdryer.MachineState.RunningMainCycle - ) + mock_washer.get_machine_state.return_value = washer.MachineState.RunningMainCycle mock_washer.get_door_open.return_value = False mock_washer.get_dispense_1_level.return_value = 3 mock_washer.get_time_remaining.return_value = 3540 @@ -148,21 +144,14 @@ def mock_washer_api(): @pytest.fixture def mock_dryer_api(): """Get a mock of a dryer.""" - mock_dryer = mock.Mock(spec=washerdryer.WasherDryer, said="said_dryer") + mock_dryer = mock.Mock(spec=dryer.Dryer, said="said_dryer") mock_dryer.name = "Dryer" mock_dryer.appliance_info = Mock( data_model="dryer", category="washer_dryer", model_number="12345" ) mock_dryer.get_online.return_value = True - mock_dryer.get_machine_state.return_value = ( - washerdryer.MachineState.RunningMainCycle - ) + mock_dryer.get_machine_state.return_value = dryer.MachineState.RunningMainCycle mock_dryer.get_door_open.return_value = False mock_dryer.get_time_remaining.return_value = 3540 - mock_dryer.get_cycle_status_filling.return_value = False - mock_dryer.get_cycle_status_rinsing.return_value = False mock_dryer.get_cycle_status_sensing.return_value = False - mock_dryer.get_cycle_status_soaking.return_value = False - mock_dryer.get_cycle_status_spinning.return_value = False - mock_dryer.get_cycle_status_washing.return_value = False return mock_dryer diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index f1eef6f7dfc..b48ed46d186 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -14,14 +14,16 @@ 'model_number': '12345', }), }), - 'ovens': dict({ - }), - 'washer_dryers': dict({ + 'dryers': dict({ 'Dryer': dict({ 'category': 'washer_dryer', 'data_model': 'dryer', 'model_number': '12345', }), + }), + 'ovens': dict({ + }), + 'washers': dict({ 'Washer': dict({ 'category': 'washer_dryer', 'data_model': 'washer', diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index 843e71b62ea..64b513abe4e 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -75,13 +75,8 @@ 'demo_mode', 'hard_stop_or_error', 'system_initialize', - 'cycle_filling', - 'cycle_rinsing', + 'cancelled', 'cycle_sensing', - 'cycle_soaking', - 'cycle_spinning', - 'cycle_washing', - 'door_open', ]), }), 'config_entry_id': , @@ -138,13 +133,8 @@ 'demo_mode', 'hard_stop_or_error', 'system_initialize', - 'cycle_filling', - 'cycle_rinsing', + 'cancelled', 'cycle_sensing', - 'cycle_soaking', - 'cycle_spinning', - 'cycle_washing', - 'door_open', ]), }), 'context': , @@ -301,7 +291,6 @@ 'cycle_soaking', 'cycle_spinning', 'cycle_washing', - 'door_open', ]), }), 'config_entry_id': , @@ -364,7 +353,6 @@ 'cycle_soaking', 'cycle_spinning', 'cycle_washing', - 'door_open', ]), }), 'context': , diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 6563f88515f..92546acd773 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -208,7 +208,8 @@ async def test_no_appliances_flow( original_aircons = mock_appliances_manager_api.return_value.aircons mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] + mock_appliances_manager_api.return_value.washers = [] + mock_appliances_manager_api.return_value.dryers = [] result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index d33bd8be0e1..848a77c6b9e 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -80,7 +80,8 @@ async def test_setup_no_appliances( ) -> None: """Test setup when there are no appliances available.""" mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] + mock_appliances_manager_api.return_value.washers = [] + mock_appliances_manager_api.return_value.dryers = [] await init_integration(hass) assert len(hass.states.async_all()) == 0 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 6e28539d661..85f0940fc4e 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -5,7 +5,8 @@ from datetime import UTC, datetime, timedelta from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from whirlpool.washerdryer import MachineState +from whirlpool.dryer import MachineState as DryerMachineState +from whirlpool.washer import MachineState as WasherMachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform @@ -63,7 +64,7 @@ async def test_washer_dryer_time_sensor( ) mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.get_machine_state.return_value = MachineState.Pause + mock_instance.get_machine_state.return_value = WasherMachineState.Pause await init_integration(hass) # Test restored state. @@ -77,7 +78,15 @@ async def test_washer_dryer_time_sensor( assert state.state == restored_datetime.isoformat() # Test new time when machine starts a cycle. - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = ( + WasherMachineState.RunningMainCycle + ) + else: + mock_instance.get_machine_state.return_value = ( + DryerMachineState.RunningMainCycle + ) + mock_instance.get_time_remaining.return_value = 60 await trigger_attr_callback(hass, mock_instance) @@ -127,7 +136,10 @@ async def test_washer_dryer_time_sensor_no_restore( now = utcnow() mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.get_machine_state.return_value = MachineState.Pause + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = WasherMachineState.Pause + else: + mock_instance.get_machine_state.return_value = DryerMachineState.Pause await init_integration(hass) state = hass.states.get(entity_id) @@ -140,7 +152,14 @@ async def test_washer_dryer_time_sensor_no_restore( assert state.state == STATE_UNKNOWN # Test new time when machine starts a cycle. - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = ( + WasherMachineState.RunningMainCycle + ) + else: + mock_instance.get_machine_state.return_value = ( + DryerMachineState.RunningMainCycle + ) mock_instance.get_time_remaining.return_value = 60 await trigger_attr_callback(hass, mock_instance) @@ -149,63 +168,87 @@ async def test_washer_dryer_time_sensor_no_restore( assert state.state == expected_time -@pytest.mark.parametrize( - ("entity_id", "mock_fixture"), - [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), - ], -) @pytest.mark.parametrize( ("machine_state", "expected_state"), [ - (MachineState.Standby, "standby"), - (MachineState.Setting, "setting"), - (MachineState.DelayCountdownMode, "delay_countdown"), - (MachineState.DelayPause, "delay_paused"), - (MachineState.SmartDelay, "smart_delay"), - (MachineState.SmartGridPause, "smart_grid_pause"), - (MachineState.Pause, "pause"), - (MachineState.RunningMainCycle, "running_maincycle"), - (MachineState.RunningPostCycle, "running_postcycle"), - (MachineState.Exceptions, "exception"), - (MachineState.Complete, "complete"), - (MachineState.PowerFailure, "power_failure"), - (MachineState.ServiceDiagnostic, "service_diagnostic_mode"), - (MachineState.FactoryDiagnostic, "factory_diagnostic_mode"), - (MachineState.LifeTest, "life_test"), - (MachineState.CustomerFocusMode, "customer_focus_mode"), - (MachineState.DemoMode, "demo_mode"), - (MachineState.HardStopOrError, "hard_stop_or_error"), - (MachineState.SystemInit, "system_initialize"), + (WasherMachineState.Standby, "standby"), + (WasherMachineState.Setting, "setting"), + (WasherMachineState.DelayCountdownMode, "delay_countdown"), + (WasherMachineState.DelayPause, "delay_paused"), + (WasherMachineState.SmartDelay, "smart_delay"), + (WasherMachineState.SmartGridPause, "smart_grid_pause"), + (WasherMachineState.Pause, "pause"), + (WasherMachineState.RunningMainCycle, "running_maincycle"), + (WasherMachineState.RunningPostCycle, "running_postcycle"), + (WasherMachineState.Exceptions, "exception"), + (WasherMachineState.Complete, "complete"), + (WasherMachineState.PowerFailure, "power_failure"), + (WasherMachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (WasherMachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (WasherMachineState.LifeTest, "life_test"), + (WasherMachineState.CustomerFocusMode, "customer_focus_mode"), + (WasherMachineState.DemoMode, "demo_mode"), + (WasherMachineState.HardStopOrError, "hard_stop_or_error"), + (WasherMachineState.SystemInit, "system_initialize"), ], ) -async def test_washer_dryer_machine_states( +async def test_washer_machine_states( hass: HomeAssistant, - entity_id: str, - mock_fixture: str, - machine_state: MachineState, + machine_state: WasherMachineState, expected_state: str, - request: pytest.FixtureRequest, + mock_washer_api, ) -> None: - """Test Washer/Dryer machine states.""" - mock_instance = request.getfixturevalue(mock_fixture) + """Test Washer machine states.""" await init_integration(hass) - mock_instance.get_machine_state.return_value = machine_state - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) + mock_washer_api.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_washer_api) + state = hass.states.get("sensor.washer_state") assert state is not None assert state.state == expected_state @pytest.mark.parametrize( - ("entity_id", "mock_fixture"), + ("machine_state", "expected_state"), [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), + (DryerMachineState.Standby, "standby"), + (DryerMachineState.Setting, "setting"), + (DryerMachineState.DelayCountdownMode, "delay_countdown"), + (DryerMachineState.DelayPause, "delay_paused"), + (DryerMachineState.SmartDelay, "smart_delay"), + (DryerMachineState.SmartGridPause, "smart_grid_pause"), + (DryerMachineState.Pause, "pause"), + (DryerMachineState.RunningMainCycle, "running_maincycle"), + (DryerMachineState.RunningPostCycle, "running_postcycle"), + (DryerMachineState.Exceptions, "exception"), + (DryerMachineState.Complete, "complete"), + (DryerMachineState.PowerFailure, "power_failure"), + (DryerMachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (DryerMachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (DryerMachineState.LifeTest, "life_test"), + (DryerMachineState.CustomerFocusMode, "customer_focus_mode"), + (DryerMachineState.DemoMode, "demo_mode"), + (DryerMachineState.HardStopOrError, "hard_stop_or_error"), + (DryerMachineState.SystemInit, "system_initialize"), + (DryerMachineState.Cancelled, "cancelled"), ], ) +async def test_dryer_machine_states( + hass: HomeAssistant, + machine_state: DryerMachineState, + expected_state: str, + mock_dryer_api, +) -> None: + """Test Dryer machine states.""" + await init_integration(hass) + + mock_dryer_api.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_dryer_api) + state = hass.states.get("sensor.dryer_state") + assert state is not None + assert state.state == expected_state + + @pytest.mark.parametrize( ( "filling", @@ -225,10 +268,8 @@ async def test_washer_dryer_machine_states( (False, False, False, False, False, True, "cycle_washing"), ], ) -async def test_washer_dryer_running_states( +async def test_washer_running_states( hass: HomeAssistant, - entity_id: str, - mock_fixture: str, filling: bool, rinsing: bool, sensing: bool, @@ -236,59 +277,25 @@ async def test_washer_dryer_running_states( spinning: bool, washing: bool, expected_state: str, - request: pytest.FixtureRequest, + mock_washer_api, ) -> None: - """Test Washer/Dryer machine states for RunningMainCycle.""" - mock_instance = request.getfixturevalue(mock_fixture) + """Test Washer machine states for RunningMainCycle.""" await init_integration(hass) - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = filling - mock_instance.get_cycle_status_rinsing.return_value = rinsing - mock_instance.get_cycle_status_sensing.return_value = sensing - mock_instance.get_cycle_status_soaking.return_value = soaking - mock_instance.get_cycle_status_spinning.return_value = spinning - mock_instance.get_cycle_status_washing.return_value = washing + mock_washer_api.get_machine_state.return_value = WasherMachineState.RunningMainCycle + mock_washer_api.get_cycle_status_filling.return_value = filling + mock_washer_api.get_cycle_status_rinsing.return_value = rinsing + mock_washer_api.get_cycle_status_sensing.return_value = sensing + mock_washer_api.get_cycle_status_soaking.return_value = soaking + mock_washer_api.get_cycle_status_spinning.return_value = spinning + mock_washer_api.get_cycle_status_washing.return_value = washing - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) + await trigger_attr_callback(hass, mock_washer_api) + state = hass.states.get("sensor.washer_state") assert state is not None assert state.state == expected_state -@pytest.mark.parametrize( - ("entity_id", "mock_fixture"), - [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), - ], -) -async def test_washer_dryer_door_open_state( - hass: HomeAssistant, - entity_id: str, - mock_fixture: str, - request: pytest.FixtureRequest, -) -> None: - """Test Washer/Dryer machine state when door is open.""" - mock_instance = request.getfixturevalue(mock_fixture) - await init_integration(hass) - - state = hass.states.get(entity_id) - assert state.state == "running_maincycle" - - mock_instance.get_door_open.return_value = True - - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) - assert state.state == "door_open" - - mock_instance.get_door_open.return_value = False - - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) - assert state.state == "running_maincycle" - - @pytest.mark.parametrize( ("entity_id", "mock_fixture", "mock_method_name", "values"), [ diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 67f6baf45bb..30cdb9080f8 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -65,7 +65,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -75,7 +74,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -147,7 +145,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -157,7 +154,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -233,7 +229,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -243,7 +238,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -315,7 +309,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -325,7 +318,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -397,7 +389,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -407,7 +398,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -478,7 +468,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -488,7 +477,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -559,7 +547,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -569,7 +556,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -640,7 +626,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -650,7 +635,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -721,7 +705,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -731,7 +714,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -854,7 +836,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -864,7 +845,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index f7c704a2c49..bfd56fbc4d4 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -1,6 +1,18 @@ # serializer version: 1 # name: test_diagnostics_cloudhook_instance dict({ + 'devices': list([ + dict({ + 'battery': 'high', + 'device_id': '**REDACTED**', + 'device_type': 'Scale', + 'first_session_date': None, + 'hashed_device_id': '**REDACTED**', + 'last_session_date': '2023-09-04T22:39:39+00:00', + 'model': 5, + 'raw_model': 'Body+', + }), + ]), 'has_cloudhooks': True, 'has_valid_external_webhook_url': True, 'received_activity_data': False, @@ -64,6 +76,18 @@ # --- # name: test_diagnostics_polling_instance dict({ + 'devices': list([ + dict({ + 'battery': 'high', + 'device_id': '**REDACTED**', + 'device_type': 'Scale', + 'first_session_date': None, + 'hashed_device_id': '**REDACTED**', + 'last_session_date': '2023-09-04T22:39:39+00:00', + 'model': 5, + 'raw_model': 'Body+', + }), + ]), 'has_cloudhooks': False, 'has_valid_external_webhook_url': False, 'received_activity_data': False, @@ -127,6 +151,18 @@ # --- # name: test_diagnostics_webhook_instance dict({ + 'devices': list([ + dict({ + 'battery': 'high', + 'device_id': '**REDACTED**', + 'device_type': 'Scale', + 'first_session_date': None, + 'hashed_device_id': '**REDACTED**', + 'last_session_date': '2023-09-04T22:39:39+00:00', + 'model': 5, + 'raw_model': 'Body+', + }), + ]), 'has_cloudhooks': False, 'has_valid_external_webhook_url': True, 'received_activity_data': False, diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index ec711def829..31c23987680 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Withings', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -50,7 +48,6 @@ 'f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Withings', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index d84074e37d3..037b6a1dfbd 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -33,6 +33,10 @@ FAKE_STATE = PilotParser( "c": 0, "w": 0, "dimming": 100, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, } ) FAKE_IP = "1.1.1.1" @@ -173,6 +177,25 @@ FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType( white_channels=1, white_to_color_ratio=80, ) +FAKE_DIMMABLE_FAN = BulbType( + bulb_type=BulbClass.FANDIM, + name="ESP03_FANDIMS_31", + features=Features( + color=False, + color_tmp=False, + effect=True, + brightness=True, + dual_head=False, + fan=True, + fan_breeze_mode=True, + fan_reverse=True, + ), + kelvin_range=KelvinRange(max=2700, min=2700), + fw_version="1.31.32", + white_channels=1, + white_to_color_ratio=20, + fan_speed_range=6, +) async def setup_integration(hass: HomeAssistant) -> MockConfigEntry: @@ -220,6 +243,9 @@ def _mocked_wizlight( bulb.async_close = AsyncMock() bulb.set_speed = AsyncMock() bulb.set_ratio = AsyncMock() + bulb.fan_set_state = AsyncMock() + bulb.fan_turn_on = AsyncMock() + bulb.fan_turn_off = AsyncMock() bulb.diagnostics = { "mocked": "mocked", "roomId": 123, diff --git a/tests/components/wiz/snapshots/test_fan.ambr b/tests/components/wiz/snapshots/test_fan.ambr new file mode 100644 index 00000000000..2c6b235e78b --- /dev/null +++ b/tests/components/wiz/snapshots/test_fan.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_entity[fan.mock_title-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'breeze', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.mock_title', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'wiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'abcabcabcabc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[fan.mock_title-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'forward', + 'friendly_name': 'Mock Title', + 'percentage': 16, + 'percentage_step': 16.666666666666668, + 'preset_mode': None, + 'preset_modes': list([ + 'breeze', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index ddf4a4f452a..946eb032f8e 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -572,3 +572,46 @@ async def test_discovered_during_onboarding(hass: HomeAssistant, source, data) - } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_replace_ignored_device(hass: HomeAssistant) -> None: + """Test we can replace an ignored device via discovery.""" + # Add ignored entry to simulate previously ignored device + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_MAC, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + # Patch discovery to find the same ignored device + with _patch_discovery(), _patch_wizlight(): + 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"], {}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_device" + # Proceed with selecting the device — previously ignored + with ( + _patch_wizlight(), + patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.wiz.async_setup", + return_value=True, + ) as mock_setup, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: FAKE_MAC} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "WiZ Dimmable White ABCABC" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/wiz/test_fan.py b/tests/components/wiz/test_fan.py new file mode 100644 index 00000000000..d15f083d431 --- /dev/null +++ b/tests/components/wiz/test_fan.py @@ -0,0 +1,232 @@ +"""Tests for fan platform.""" + +from typing import Any +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.components.wiz.fan import PRESET_MODE_BREEZE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import FAKE_DIMMABLE_FAN, FAKE_MAC, async_push_update, async_setup_integration + +from tests.common import snapshot_platform + +ENTITY_ID = "fan.mock_title" + +INITIAL_PARAMS = { + "mac": FAKE_MAC, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, +} + + +@patch("homeassistant.components.wiz.PLATFORMS", [Platform.FAN]) +async def test_entity( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test the fan entity.""" + entry = (await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN))[1] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +def _update_params( + params: dict[str, Any], + state: int | None = None, + mode: int | None = None, + speed: int | None = None, + reverse: int | None = None, +) -> dict[str, Any]: + """Get the parameters for the update.""" + if state is not None: + params["fanState"] = state + if mode is not None: + params["fanMode"] = mode + if speed is not None: + params["fanSpeed"] = speed + if reverse is not None: + params["fanRevrs"] = reverse + return params + + +async def test_turn_on_off(hass: HomeAssistant) -> None: + """Test turning the fan on and off.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": None, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_PRESET_MODE] is None + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_turn_off.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + +async def test_fan_set_preset_mode(hass: HomeAssistant) -> None: + """Test setting the fan preset mode.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + +async def test_fan_set_percentage(hass: HomeAssistant) -> None: + """Test setting the fan percentage.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 50 + + +async def test_fan_set_direction(hass: HomeAssistant) -> None: + """Test setting the fan direction.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_REVERSE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 1} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_FORWARD}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 0} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index d8a29ed7c48..b7bb8a1eea1 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -70,7 +70,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -80,7 +79,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 877c8baa93e..8f94c270984 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -78,7 +78,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -88,7 +87,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -172,7 +170,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -182,7 +179,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 6cfbe1de5d4..a981b741852 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -80,7 +80,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -90,7 +89,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -312,7 +310,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -322,7 +319,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -406,7 +402,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -416,7 +411,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.99.0b1', 'via_device_id': None, }) @@ -500,7 +494,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -510,7 +503,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.99.0b1', 'via_device_id': None, }) diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index c32bc314cc0..43e91f7b485 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -71,7 +71,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -81,7 +80,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -156,7 +154,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -166,7 +163,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -242,7 +238,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -252,7 +247,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -328,7 +322,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -338,7 +331,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 57635a8cb74..90e731f3fe9 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -373,6 +373,7 @@ async def test_single_segment_with_keep_main_light( hass.config_entries.async_update_entry( init_integration, options={CONF_KEEP_MAIN_LIGHT: True} ) + await hass.config_entries.async_reload(init_integration.entry_id) await hass.async_block_till_done() assert (state := hass.states.get("light.wled_rgb_light_main")) diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 53b2f6205cb..8590c4ba725 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -17,7 +17,6 @@ '58717', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '58717', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr index 147d66f2b69..ee485fe3980 100644 --- a/tests/components/wmspro/snapshots/test_init.ambr +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '19239', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '19239', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -50,7 +48,6 @@ '58717', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -60,7 +57,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '58717', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -83,7 +79,6 @@ '97358', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -93,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '97358', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -116,7 +110,6 @@ '19239', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -126,7 +119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '19239', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -149,7 +141,6 @@ '58717', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -159,7 +150,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '58717', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -182,7 +172,6 @@ '97358', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -192,7 +181,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '97358', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -215,7 +203,6 @@ '116682', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -225,7 +212,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '116682', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -248,7 +234,6 @@ '172555', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -258,7 +243,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '172555', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -281,7 +265,6 @@ '18894', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -291,7 +274,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '18894', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -314,7 +296,6 @@ '230952', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -324,7 +305,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '230952', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -347,7 +327,6 @@ '284942', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -357,7 +336,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '284942', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -380,7 +358,6 @@ '328518', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -390,7 +367,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '328518', - 'suggested_area': 'Alle', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index d6ccebfb5ea..9efbadff951 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -17,7 +17,6 @@ '97358', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '97358', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index b5dddb368c9..b9053992ddc 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -31,7 +31,6 @@ '42581', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -41,7 +40,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '42581', - 'suggested_area': 'Raum 0', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index dc56d2bf988..c180b213a31 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -50,7 +50,7 @@ async def test_config_flow_from_dhcp( ) -> None: """Test we can handle DHCP discovery to create a config entry.""" info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -109,7 +109,7 @@ async def test_config_flow_from_dhcp_add_mac( assert hass.config_entries.async_entries(DOMAIN)[0].unique_id is None info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -126,7 +126,7 @@ async def test_config_flow_from_dhcp_ip_update( ) -> None: """Test we can use DHCP discovery to update IP in a config entry.""" info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -154,7 +154,7 @@ async def test_config_flow_from_dhcp_ip_update( assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "00:11:22:33:44:55" info = DhcpServiceInfo( - ip="5.6.7.8", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="5.6.7.8", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -172,7 +172,7 @@ async def test_config_flow_from_dhcp_no_update( ) -> None: """Test we do not use DHCP discovery to overwrite hostname with IP in config entry.""" info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -200,7 +200,7 @@ async def test_config_flow_from_dhcp_no_update( assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "00:11:22:33:44:55" info = DhcpServiceInfo( - ip="5.6.7.8", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="5.6.7.8", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index c5b23cc8e79..d66c1d2285b 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -17,7 +17,6 @@ '1234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WOLF GmbH', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 2c0e9aa1123..c618c5fd830 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -108,6 +108,7 @@ async def test_form_province_no_alias(hass: HomeAssistant) -> None: "name": "Workday Sensor", "country": "US", "excludes": ["sat", "sun", "holiday"], + "language": "en_US", "days_offset": 0, "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 2735175b49b..653b6810197 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -45,6 +45,7 @@ async def test_update_options( new_options["add_holidays"] = ["2023-04-12"] hass.config_entries.async_update_entry(entry, options=new_options) + await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() entry_check = hass.config_entries.async_get_entry("1") @@ -61,8 +62,4 @@ async def test_workday_subdiv_aliases() -> None: years=2025, ) subdiv_aliases = country.get_subdivision_aliases() - assert subdiv_aliases["GES"] == [ # codespell:ignore - "Alsace", - "Champagne-Ardenne", - "Lorraine", - ] + assert subdiv_aliases["6AE"] == ["Alsace"] diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 4540cdaabfd..de82dc08719 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -69,6 +69,29 @@ TTS_INFO = Info( ) ] ) +TTS_STREAMING_INFO = Info( + tts=[ + TtsProgram( + name="Test Streaming TTS", + description="Test Streaming TTS", + installed=True, + attribution=TEST_ATTR, + voices=[ + TtsVoice( + name="Test Voice", + description="Test Voice", + installed=True, + attribution=TEST_ATTR, + languages=["en-US"], + speakers=[TtsVoiceSpeaker(name="Test Speaker")], + version=None, + ) + ], + version=None, + supports_synthesize_streaming=True, + ) + ] +) WAKE_WORD_INFO = Info( wake=[ WakeProgram( @@ -155,9 +178,15 @@ class MockAsyncTcpClient: self.port: int | None = None self.written: list[Event] = [] self.responses = responses + self.is_connected: bool | None = None async def connect(self) -> None: """Connect.""" + self.is_connected = True + + async def disconnect(self) -> None: + """Disconnect.""" + self.is_connected = False async def write_event(self, event: Event): """Send.""" diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 125edc547c6..2974bb4b013 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -19,6 +19,7 @@ from . import ( SATELLITE_INFO, STT_INFO, TTS_INFO, + TTS_STREAMING_INFO, WAKE_WORD_INFO, ) @@ -148,6 +149,20 @@ async def init_wyoming_tts( return tts_config_entry +@pytest.fixture +async def init_wyoming_streaming_tts( + hass: HomeAssistant, tts_config_entry: ConfigEntry +) -> ConfigEntry: + """Initialize Wyoming streaming TTS.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=TTS_STREAMING_INFO, + ): + await hass.config_entries.async_setup(tts_config_entry.entry_id) + + return tts_config_entry + + @pytest.fixture async def init_wyoming_wake_word( hass: HomeAssistant, wake_word_config_entry: ConfigEntry diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 7ca5204e66c..53cc02eaacf 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -32,6 +32,43 @@ }), ]) # --- +# name: test_get_tts_audio_streaming + list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello ', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), + dict({ + 'data': dict({ + 'text': 'Word.', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), + dict({ + 'data': dict({ + 'text': 'Hello Word.', + }), + 'payload': None, + 'type': 'synthesize', + }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), + ]) +# --- # name: test_voice_speaker list([ dict({ diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py index 24423264f93..d03f2622c71 100644 --- a/tests/components/wyoming/test_devices.py +++ b/tests/components/wyoming/test_devices.py @@ -8,13 +8,14 @@ from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import area_registry as ar, device_registry as dr async def test_device_registry_info( hass: HomeAssistant, satellite_device: SatelliteDevice, satellite_config_entry: ConfigEntry, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test info in device registry.""" @@ -26,7 +27,7 @@ async def test_device_registry_info( ) assert device is not None assert device.name == "Test Satellite" - assert device.suggested_area == "Office" + assert device.area_id == area_registry.async_get_area_by_name("Office").id # Check associated entities assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index dec5d6cbebd..870e2696601 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -1472,3 +1472,184 @@ async def test_tts_timeout( # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_satellite_tts_streaming(hass: HomeAssistant) -> None: + """Test running a streaming TTS pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline(start_stage=PipelineStage.ASR, end_stage=PipelineStage.TTS).event(), + ] + + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + audio_chunk_received = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for chunk in stt_stream: + if chunk: + audio_chunk_received.set() + break + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + # Send TTS info early + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.RUN_START, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + + # Speech-to-text started + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + # Push in some audio + mock_client.inject_event( + AudioChunk(rate=16000, width=2, channels=1, audio=bytes(1024)).event() + ) + + # User started speaking + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + # User stopped speaking + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + # Speech-to-text transcription + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + # Intent progress starts TTS streaming early with info received in the + # run-start event. + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.INTENT_PROGRESS, + {"tts_start_streaming": True}, + ) + ) + + # TTS events are sent now. In practice, these would be streamed as text + # chunks are generated. + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"1234" + + # Text-to-speech text + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + + # synthesize event is sent with complete message for non-streaming clients + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Because we started streaming TTS after intent progress, we should not + # stream it again on tts-end. + with patch( + "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite._stream_tts" + ) as mock_stream_tts: + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + + mock_stream_tts.assert_not_called() + + # Pipeline finished + pipeline_event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index c658bff1d0c..efcf464eebb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -8,7 +8,8 @@ import wave import pytest from syrupy.assertion import SnapshotAssertion -from wyoming.audio import AudioChunk, AudioStop +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.tts import SynthesizeStopped from homeassistant.components import tts, wyoming from homeassistant.core import HomeAssistant @@ -43,14 +44,15 @@ async def test_get_tts_audio( hass: HomeAssistant, init_wyoming_tts, snapshot: SnapshotAssertion ) -> None: """Test get audio.""" + entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_tts") + assert entity is not None + assert not entity.async_supports_streaming_input() + audio = bytes(100) - audio_events = [ - AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), - AudioStop().event(), - ] # Verify audio audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -76,7 +78,10 @@ async def test_get_tts_audio( assert wav_file.getframerate() == 16000 assert wav_file.getsampwidth() == 2 assert wav_file.getnchannels() == 1 - assert wav_file.readframes(wav_file.getnframes()) == audio + + # nframes = 0 due to streaming + assert len(data) == len(audio) + 44 # WAVE header is 44 bytes + assert data[44:] == audio assert mock_client.written == snapshot @@ -87,6 +92,7 @@ async def test_get_tts_audio_different_formats( """Test changing preferred audio format.""" audio = bytes(16000 * 2 * 1) # one second audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -122,6 +128,7 @@ async def test_get_tts_audio_different_formats( # MP3 is the default audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -166,6 +173,7 @@ async def test_get_tts_audio_audio_oserror( """Test get audio and error raising.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -196,6 +204,7 @@ async def test_voice_speaker( """Test using a different voice and speaker.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -215,3 +224,52 @@ async def test_voice_speaker( ), ) assert mock_client.written == snapshot + + +async def test_get_tts_audio_streaming( + hass: HomeAssistant, init_wyoming_streaming_tts, snapshot: SnapshotAssertion +) -> None: + """Test get audio with streaming.""" + entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_streaming_tts") + assert entity is not None + assert entity.async_supports_streaming_input() + + audio = bytes(100) + + # Verify audio + audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + SynthesizeStopped().event(), + ] + + async def message_gen(): + yield "Hello " + yield "Word." + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + stream = tts.async_create_stream( + hass, + "tts.test_streaming_tts", + "en-US", + options={tts.ATTR_PREFERRED_FORMAT: "wav"}, + ) + stream.async_set_message_stream(message_gen()) + data = b"".join([chunk async for chunk in stream.async_stream_result()]) + + # Ensure client was disconnected properly + assert mock_client.is_connected is False + + assert data is not None + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + assert wav_file.getframerate() == 16000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 1 + assert wav_file.getnframes() == 0 # streaming + assert data[44:] == audio # WAV header is 44 bytes + + assert mock_client.written == snapshot diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index f5625d4e74d..abfe140f6cb 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -262,7 +262,7 @@ async def test_xiaomi_hhccjcy01(hass: HomeAssistant) -> None: cond_sensor_attribtes = cond_sensor.attributes assert cond_sensor.state == "599" assert cond_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 3E7A Conductivity" - assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "µS/cm" + assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "μS/cm" assert cond_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" moist_sensor = hass.states.get("sensor.plant_sensor_3e7a_moisture") @@ -351,7 +351,7 @@ async def test_xiaomi_hhccjcy01_not_connectable(hass: HomeAssistant) -> None: cond_sensor_attribtes = cond_sensor.attributes assert cond_sensor.state == "599" assert cond_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 3E7A Conductivity" - assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "µS/cm" + assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "μS/cm" assert cond_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" moist_sensor = hass.states.get("sensor.plant_sensor_3e7a_moisture") @@ -438,7 +438,7 @@ async def test_xiaomi_hhccjcy01_only_some_sources_connectable( cond_sensor_attribtes = cond_sensor.attributes assert cond_sensor.state == "599" assert cond_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Plant Sensor 3E7A Conductivity" - assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "µS/cm" + assert cond_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "μS/cm" assert cond_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" moist_sensor = hass.states.get("sensor.plant_sensor_3e7a_moisture") @@ -653,7 +653,7 @@ async def test_hhccjcy10_uuid(hass: HomeAssistant) -> None: cond_sensor_attr = cond_sensor.attributes assert cond_sensor.state == "91" assert cond_sensor_attr[ATTR_FRIENDLY_NAME] == "Plant Sensor 5BFC Conductivity" - assert cond_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "µS/cm" + assert cond_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "μS/cm" assert cond_sensor_attr[ATTR_STATE_CLASS] == "measurement" moist_sensor = hass.states.get("sensor.plant_sensor_5bfc_moisture") @@ -700,7 +700,7 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Weight non stabilized" + == "Mi Smart Scale (B5DC) Weight non-stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -742,7 +742,7 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Weight non stabilized" + == "Mi Body Composition Scale (B5DC) Weight non-stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index 9db0d760efb..226d0bdbba9 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -17,7 +17,6 @@ 'tmt100', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Yale Home Inc.', @@ -27,7 +26,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'tmt100 Name', 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, }) diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index 00653a9b0c1..3f89fe08525 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -21,7 +21,6 @@ 'online_with_doorsense', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Yale Home Inc.', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'online_with_doorsense Name', 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, }) diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index 1b0df05db2c..c272036097d 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -37,7 +37,7 @@ def _get_mock_push_lock(): mock_push_lock.wait_for_first_update = AsyncMock() mock_push_lock.stop = AsyncMock() mock_push_lock.lock_state = LockState( - LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None + LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None, None, None ) mock_push_lock.lock_status = LockStatus.UNLOCKED mock_push_lock.door_status = DoorStatus.CLOSED diff --git a/tests/components/yolink/conftest.py b/tests/components/yolink/conftest.py new file mode 100644 index 00000000000..2090cd57f2f --- /dev/null +++ b/tests/components/yolink/conftest.py @@ -0,0 +1,77 @@ +"""Provide common fixtures for the YoLink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from yolink.home_manager import YoLinkHome + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.yolink.api import ConfigEntryAuth +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "12345" +CLIENT_SECRET = "6789" +DOMAIN = "yolink" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="mock_auth_manager") +def mock_auth_manager() -> Generator[MagicMock]: + """Mock the authentication manager.""" + with patch( + "homeassistant.components.yolink.api.ConfigEntryAuth", autospec=True + ) as mock_auth: + mock_auth.return_value = MagicMock(spec=ConfigEntryAuth) + yield mock_auth + + +@pytest.fixture(name="mock_yolink_home") +def mock_yolink_home() -> Generator[AsyncMock]: + """Mock YoLink home instance.""" + with patch( + "homeassistant.components.yolink.YoLinkHome", autospec=True + ) as mock_home: + mock_home.return_value = AsyncMock(spec=YoLinkHome) + yield mock_home + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a config entry for YoLink.""" + config_entry = MockConfigEntry( + unique_id=DOMAIN, + domain=DOMAIN, + title="yolink", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "create", + }, + }, + options={}, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/yolink/test_init.py b/tests/components/yolink/test_init.py new file mode 100644 index 00000000000..11d0528dcce --- /dev/null +++ b/tests/components/yolink/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the yolink integration.""" + +import pytest + +from homeassistant.components.yolink import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_credentials", "mock_auth_manager", "mock_yolink_home") +async def test_device_remove_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can only remove a device that no longer exists.""" + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "stale_device_id")}, + ) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + device_entry = device_entries[0] + assert device_entry.identifiers == {(DOMAIN, "stale_device_id")} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 0 diff --git a/tests/components/zha/data.py b/tests/components/zha/data.py index 80a3df524cd..11c09229eb8 100644 --- a/tests/components/zha/data.py +++ b/tests/components/zha/data.py @@ -9,6 +9,7 @@ BASE_CUSTOM_CONFIGURATION = { "valueMax": 6553.6, "name": "default_light_transition", "optional": True, + "required": False, "default": 0, }, { @@ -40,6 +41,7 @@ BASE_CUSTOM_CONFIGURATION = { "valueMin": 0, "name": "consider_unavailable_mains", "optional": True, + "required": False, "default": 7200, }, { @@ -47,6 +49,7 @@ BASE_CUSTOM_CONFIGURATION = { "valueMin": 0, "name": "consider_unavailable_battery", "optional": True, + "required": False, "default": 21600, }, { @@ -80,6 +83,7 @@ CONFIG_WITH_ALARM_OPTIONS = { "valueMax": 6553.6, "name": "default_light_transition", "optional": True, + "required": False, "default": 0, }, { @@ -111,6 +115,7 @@ CONFIG_WITH_ALARM_OPTIONS = { "valueMin": 0, "name": "consider_unavailable_mains", "optional": True, + "required": False, "default": 7200, }, { @@ -118,6 +123,7 @@ CONFIG_WITH_ALARM_OPTIONS = { "valueMin": 0, "name": "consider_unavailable_battery", "optional": True, + "required": False, "default": 21600, }, { diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 44fb913489d..4d90942fb97 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -36,6 +36,7 @@ }), 'network_key': '**REDACTED**', 'nwk_addresses': dict({ + '11:22:33:44:55:66:77:88': 4660, }), 'nwk_manager_id': 0, 'nwk_update_id': 0, @@ -168,7 +169,6 @@ dict({ 'id': '0x0010', 'name': 'cie_addr', - 'unsupported': False, 'value': list([ 50, 79, @@ -181,68 +181,18 @@ ]), 'zcl_type': 'EUI64', }), - dict({ - 'id': '0x0013', - 'name': 'current_zone_sensitivity_level', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint8', - }), dict({ 'id': '0x0012', 'name': 'num_zone_sensitivity_levels_supported', 'unsupported': True, - 'value': None, 'zcl_type': 'uint8', }), - dict({ - 'id': '0x0011', - 'name': 'zone_id', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint8', - }), - dict({ - 'id': '0x0000', - 'name': 'zone_state', - 'unsupported': False, - 'value': None, - 'zcl_type': 'enum8', - }), - dict({ - 'id': '0x0002', - 'name': 'zone_status', - 'unsupported': False, - 'value': None, - 'zcl_type': 'map16', - }), - dict({ - 'id': '0x0001', - 'name': 'zone_type', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint16', - }), ]), 'cluster_id': '0x0500', 'endpoint_attribute': 'ias_zone', }), dict({ 'attributes': list([ - dict({ - 'id': '0xfffd', - 'name': 'cluster_revision', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint16', - }), - dict({ - 'id': '0xfffe', - 'name': 'reporting_status', - 'unsupported': False, - 'value': None, - 'zcl_type': 'enum8', - }), ]), 'cluster_id': '0x0501', 'endpoint_attribute': 'ias_ace', diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 0e78a9a1b5b..d32dd191527 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from zigpy.profiles import zha +from zigpy.types import EUI64, NWK from zigpy.zcl.clusters import security from homeassistant.components.zha.helpers import ( @@ -71,6 +72,10 @@ async def test_diagnostics_for_config_entry( gateway.application_controller.energy_scan.side_effect = None gateway.application_controller.energy_scan.return_value = scan + gateway.application_controller.state.network_info.nwk_addresses = { + EUI64.convert("11:22:33:44:55:66:77:88"): NWK(0x1234) + } + diagnostics_data = await get_diagnostics_for_config_entry( hass, hass_client, config_entry ) diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index f52b403869e..9c833cde0be 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -71,12 +71,14 @@ async def test_zcl_schema_conversions(hass: HomeAssistant) -> None: "options": ["Execute if off present"], "name": "options_mask", "optional": True, + "required": False, }, { "type": "multi_select", "options": ["Execute if off"], "name": "options_override", "optional": True, + "required": False, }, ] vol_schema = voluptuous_serialize.convert( diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index c8cbc407106..04d190b170c 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -47,6 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .common import find_entity_id, update_attribute_cache @@ -156,7 +157,6 @@ async def setup_test_data( ) ) zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee) - zha_device_proxy.device.async_update_sw_build_id(installed_fw_version) return zha_device_proxy, cluster, fw_image, installed_fw_version @@ -643,3 +643,26 @@ async def test_update_release_notes( assert "Some lengthy release notes" in result["result"] assert OTA_MESSAGE_RELIABILITY in result["result"] assert OTA_MESSAGE_BATTERY_POWERED in result["result"] + + +async def test_update_version_sync_device_registry( + hass: HomeAssistant, + setup_zha, + zigpy_device_mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test firmware version syncing between the ZHA device and Home Assistant.""" + await setup_zha() + zha_device, _, _, _ = await setup_test_data(hass, zigpy_device_mock) + + zha_device.device.async_update_firmware_version("0x12345678") + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.device.ieee))} + ) + assert reg_device.sw_version == "0x12345678" + + zha_device.device.async_update_firmware_version("0xabcd1234") + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.device.ieee))} + ) + assert reg_device.sw_version == "0xabcd1234" diff --git a/tests/components/zimi/common.py b/tests/components/zimi/common.py new file mode 100644 index 00000000000..13582b3d42c --- /dev/null +++ b/tests/components/zimi/common.py @@ -0,0 +1,81 @@ +"""Common items for testing the zimi component.""" + +from unittest.mock import MagicMock, create_autospec, patch + +from zcc.device import ControlPointDevice + +from homeassistant.components.zimi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +DEVICE_INFO = { + "id": "test-device-id", + "name": "unknown", + "manufacturer": "Zimi", + "model": "Controller XYZ", + "hwVersion": "2.2.2", + "fwVersion": "3.3.3", +} + +ENTITY_INFO = { + "id": "test-entity-id", + "name": "Test Entity Name", + "room": "Test Entity Room", + "type": "unknown", +} + +INPUT_HOST = "192.168.1.100" +INPUT_PORT = 5003 + + +def mock_api_device( + device_name: str | None = None, + entity_type: str | None = None, +) -> MagicMock: + """Mock a Zimi ControlPointDevice which is used in the zcc API with defaults.""" + + mock_api_device = create_autospec(ControlPointDevice) + + mock_api_device.identifier = ENTITY_INFO["id"] + mock_api_device.room = ENTITY_INFO["room"] + mock_api_device.name = ENTITY_INFO["name"] + mock_api_device.type = entity_type or ENTITY_INFO["type"] + + mock_manfacture_info = MagicMock() + mock_manfacture_info.identifier = DEVICE_INFO["id"] + mock_manfacture_info.manufacturer = DEVICE_INFO["manufacturer"] + mock_manfacture_info.model = DEVICE_INFO["model"] + mock_manfacture_info.name = device_name or DEVICE_INFO["name"] + mock_manfacture_info.hwVersion = DEVICE_INFO["hwVersion"] + mock_manfacture_info.firmwareVersion = DEVICE_INFO["fwVersion"] + + mock_api_device.manufacture_info = mock_manfacture_info + + mock_api_device.brightness = 0 + mock_api_device.percentage = 0 + + return mock_api_device + + +async def setup_platform( + hass: HomeAssistant, + platform: str, +) -> MockConfigEntry: + """Set up the specified Zimi platform.""" + + if not platform: + raise ValueError("Platform must be specified") + + mock_config = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: INPUT_HOST, CONF_PORT: INPUT_PORT} + ) + mock_config.add_to_hass(hass) + + with patch("homeassistant.components.zimi.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return mock_config diff --git a/tests/components/zimi/conftest.py b/tests/components/zimi/conftest.py new file mode 100644 index 00000000000..b26c2f89784 --- /dev/null +++ b/tests/components/zimi/conftest.py @@ -0,0 +1,30 @@ +"""Test fixtures for Zimi component.""" + +from unittest.mock import MagicMock, patch + +import pytest + +INPUT_MAC = "aa:bb:cc:dd:ee:ff" + + +API_INFO = { + "brand": "Zimi", + "network_name": "Test Network", + "firmware_version": "1.1.1", +} + + +@pytest.fixture +def mock_api(): + """Mock the API with defaults.""" + with patch("homeassistant.components.zimi.async_connect_to_controller") as mock: + mock_api = mock.return_value + mock_api.describe = MagicMock() + mock_api.disconnect = MagicMock() + mock_api.connect.return_value = True + mock_api.mac = INPUT_MAC + mock_api.brand = API_INFO["brand"] + mock_api.network_name = API_INFO["network_name"] + mock_api.firmware_version = API_INFO["firmware_version"] + + yield mock_api diff --git a/tests/components/zimi/snapshots/test_cover.ambr b/tests/components/zimi/snapshots/test_cover.ambr new file mode 100644 index 00000000000..66d74f36771 --- /dev/null +++ b/tests/components/zimi/snapshots/test_cover.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_cover_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'garage', + 'friendly_name': 'Cover Controller Test Entity Name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'opening', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_fan.ambr b/tests/components/zimi/snapshots/test_fan.ambr new file mode 100644 index 00000000000..6b3f226b4f9 --- /dev/null +++ b/tests/components/zimi/snapshots/test_fan.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_fan_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fan Controller Test Entity Name', + 'percentage': 1, + 'percentage_step': 12.5, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fan_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_light.ambr b/tests/components/zimi/snapshots/test_light.ambr new file mode 100644 index 00000000000..372e2c937ca --- /dev/null +++ b/tests/components/zimi/snapshots/test_light.ambr @@ -0,0 +1,38 @@ +# serializer version: 1 +# name: test_dimmer_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 0, + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_switch.ambr b/tests/components/zimi/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c96fc99b908 --- /dev/null +++ b/tests/components/zimi/snapshots/test_switch.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_switch_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch Controller Test Entity Name', + }), + 'context': , + 'entity_id': 'switch.switch_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/test_cover.py b/tests/components/zimi/test_cover.py new file mode 100644 index 00000000000..68809af49e6 --- /dev/null +++ b/tests/components/zimi/test_cover.py @@ -0,0 +1,77 @@ +"""Test the Zimi cover entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_cover_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests cover entity.""" + + device_name = "Cover Controller" + entity_key = "cover.cover_controller_test_entity_name" + entity_type = Platform.COVER + + mock_api.doors = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_CLOSE_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_CLOSE_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].close_door.called + + assert SERVICE_OPEN_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_OPEN_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].open_door.called + + assert SERVICE_SET_COVER_POSITION in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_key, "position": 50}, + blocking=True, + ) + assert mock_api.doors[0].open_to_percentage.called diff --git a/tests/components/zimi/test_fan.py b/tests/components/zimi/test_fan.py new file mode 100644 index 00000000000..ed87b32a61f --- /dev/null +++ b/tests/components/zimi/test_fan.py @@ -0,0 +1,75 @@ +"""Test the Zimi fan entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import FanEntityFeature +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_fan_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests fan entity.""" + + device_name = "Fan Controller" + entity_key = "fan.fan_controller_test_entity_name" + entity_type = Platform.FAN + + mock_api.fans = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_off.called + + assert "set_percentage" in services[entity_type] + await hass.services.async_call( + entity_type, + "set_percentage", + {"entity_id": entity_key, "percentage": 50}, + blocking=True, + ) + assert mock_api.fans[0].set_fanspeed.called diff --git a/tests/components/zimi/test_light.py b/tests/components/zimi/test_light.py new file mode 100644 index 00000000000..7716a6368fe --- /dev/null +++ b/tests/components/zimi/test_light.py @@ -0,0 +1,119 @@ +"""Test the Zimi light entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ColorMode +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_light_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests lights entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.ONOFF], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_off.called + + +async def test_dimmer_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests dimmer entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "dimmer" + entity_type_override = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.BRIGHTNESS], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called + + assert SERVICE_TURN_OFF in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called diff --git a/tests/components/zimi/test_switch.py b/tests/components/zimi/test_switch.py new file mode 100644 index 00000000000..2464757e7b6 --- /dev/null +++ b/tests/components/zimi/test_switch.py @@ -0,0 +1,60 @@ +"""Test the Zimi switch entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_switch_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests switch entity.""" + + device_name = "Switch Controller" + entity_key = "switch.switch_controller_test_entity_name" + entity_type = "switch" + + mock_api.outlets = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.SWITCH) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_off.called diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py new file mode 100644 index 00000000000..ab78fc90bae --- /dev/null +++ b/tests/components/zone/test_condition.py @@ -0,0 +1,203 @@ +"""The tests for the location condition.""" + +import pytest + +from homeassistant.components.zone import condition as zone_condition +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConditionError +from homeassistant.helpers import condition, config_validation as cv + + +async def test_zone_raises(hass: HomeAssistant) -> None: + """Test that zone raises ConditionError on errors.""" + config = { + "condition": "zone", + "entity_id": "device_tracker.cat", + "zone": "zone.home", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + with pytest.raises(ConditionError, match="no zone"): + zone_condition.zone(hass, zone_ent=None, entity="sensor.any") + + with pytest.raises(ConditionError, match="unknown zone"): + test(hass) + + hass.states.async_set( + "zone.home", + "zoning", + {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + + with pytest.raises(ConditionError, match="no entity"): + zone_condition.zone(hass, zone_ent="zone.home", entity=None) + + with pytest.raises(ConditionError, match="unknown entity"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat"}, + ) + + with pytest.raises(ConditionError, match="latitude"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat", "latitude": 2.1}, + ) + + with pytest.raises(ConditionError, match="longitude"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1}, + ) + + # All okay, now test multiple failed conditions + assert test(hass) + + config = { + "condition": "zone", + "entity_id": ["device_tracker.cat", "device_tracker.dog"], + "zone": ["zone.home", "zone.work"], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + with pytest.raises(ConditionError, match="dog"): + test(hass) + + with pytest.raises(ConditionError, match="work"): + test(hass) + + hass.states.async_set( + "zone.work", + "zoning", + {"name": "work", "latitude": 20, "longitude": 10, "radius": 25000}, + ) + + hass.states.async_set( + "device_tracker.dog", + "work", + {"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1}, + ) + + assert test(hass) + + +async def test_zone_multiple_entities(hass: HomeAssistant) -> None: + """Test with multiple entities in condition.""" + config = { + "condition": "and", + "conditions": [ + { + "alias": "Zone Condition", + "condition": "zone", + "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], + "zone": "zone.home", + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set( + "zone.home", + "zoning", + {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + + hass.states.async_set( + "device_tracker.person_1", + "home", + {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, + ) + hass.states.async_set( + "device_tracker.person_2", + "home", + {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, + ) + assert test(hass) + + hass.states.async_set( + "device_tracker.person_1", + "home", + {"friendly_name": "person_1", "latitude": 20.1, "longitude": 10.1}, + ) + hass.states.async_set( + "device_tracker.person_2", + "home", + {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, + ) + assert not test(hass) + + hass.states.async_set( + "device_tracker.person_1", + "home", + {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, + ) + hass.states.async_set( + "device_tracker.person_2", + "home", + {"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1}, + ) + assert not test(hass) + + +async def test_multiple_zones(hass: HomeAssistant) -> None: + """Test with multiple entities in condition.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "zone", + "entity_id": "device_tracker.person", + "zone": ["zone.home", "zone.work"], + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set( + "zone.home", + "zoning", + {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + hass.states.async_set( + "zone.work", + "zoning", + {"name": "work", "latitude": 20.1, "longitude": 10.1, "radius": 10}, + ) + + hass.states.async_set( + "device_tracker.person", + "home", + {"friendly_name": "person", "latitude": 2.1, "longitude": 1.1}, + ) + assert test(hass) + + hass.states.async_set( + "device_tracker.person", + "home", + {"friendly_name": "person", "latitude": 20.1, "longitude": 10.1}, + ) + assert test(hass) + + hass.states.async_set( + "device_tracker.person", + "home", + {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, + ) + assert not test(hass) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 138bcd63ede..f60c0169055 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -325,6 +325,12 @@ def ge_12730_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fan_ge_12730_state.json", DOMAIN) +@pytest.fixture(name="enbrighten_58446_zwa4013_state", scope="package") +def enbrighten_58446_zwa4013_state_fixture() -> dict[str, Any]: + """Load the Enbrighten/GE 58446/zwa401 node state fixture data.""" + return load_json_object_fixture("enbrighten_58446_zwa4013_state.json", DOMAIN) + + @pytest.fixture(name="aeotec_radiator_thermostat_state", scope="package") def aeotec_radiator_thermostat_state_fixture() -> dict[str, Any]: """Load the Aeotec Radiator Thermostat node state fixture data.""" @@ -421,6 +427,12 @@ def fortrezz_ssa1_siren_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fortrezz_ssa1_siren_state.json", DOMAIN) +@pytest.fixture(name="fortrezz_ssa2_siren_state", scope="package") +def fortrezz_ssa2_siren_state_fixture() -> dict[str, Any]: + """Load the fortrezz ssa2 siren node state fixture data.""" + return load_json_object_fixture("fortrezz_ssa2_siren_state.json", DOMAIN) + + @pytest.fixture(name="fortrezz_ssa3_siren_state", scope="package") def fortrezz_ssa3_siren_state_fixture() -> dict[str, Any]: """Load the fortrezz ssa3 siren node state fixture data.""" @@ -532,6 +544,24 @@ def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="nabu_casa_zwa2_state") +def nabu_casa_zwa2_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2.""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_state.json", DOMAIN), + ) + + +@pytest.fixture(name="nabu_casa_zwa2_legacy_state") +def nabu_casa_zwa2_legacy_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2 (legacy firmware).""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_legacy_state.json", DOMAIN), + ) + + # model fixtures @@ -541,12 +571,6 @@ def mock_listen_block_fixture() -> asyncio.Event: return asyncio.Event() -@pytest.fixture(name="listen_result") -def listen_result_fixture() -> asyncio.Future[None]: - """Mock a listen result.""" - return asyncio.Future() - - @pytest.fixture(name="client") def mock_client_fixture( controller_state: dict[str, Any], @@ -554,7 +578,6 @@ def mock_client_fixture( version_state: dict[str, Any], log_config_state: dict[str, Any], listen_block: asyncio.Event, - listen_result: asyncio.Future[None], ): """Mock a client.""" with patch( @@ -563,15 +586,16 @@ def mock_client_fixture( client = client_class.return_value async def connect(): + listen_block.clear() await asyncio.sleep(0) client.connected = True async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() await listen_block.wait() - await listen_result async def disconnect(): + listen_block.set() client.connected = False client.connect = AsyncMock(side_effect=connect) @@ -1078,6 +1102,14 @@ def ge_12730_fixture(client, ge_12730_state) -> Node: return node +@pytest.fixture(name="enbrighten_58446_zwa4013") +def enbrighten_58446_zwa4013_fixture(client, enbrighten_58446_zwa4013_state) -> Node: + """Mock a Enbrighten_58446/zwa4013 fan controller node.""" + node = Node(client, copy.deepcopy(enbrighten_58446_zwa4013_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="inovelli_lzw36") def inovelli_lzw36_fixture(client, inovelli_lzw36_state) -> Node: """Mock a Inovelli LZW36 fan controller node.""" @@ -1192,6 +1224,14 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state) -> Node: return node +@pytest.fixture(name="fortrezz_ssa2_siren") +def fortrezz_ssa2_siren_fixture(client, fortrezz_ssa2_siren_state) -> Node: + """Mock a fortrezz ssa2 siren node.""" + node = Node(client, copy.deepcopy(fortrezz_ssa2_siren_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="fortrezz_ssa3_siren") def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state) -> Node: """Mock a fortrezz ssa3 siren node.""" @@ -1344,3 +1384,23 @@ def zcombo_smoke_co_alarm_fixture( node = Node(client, zcombo_smoke_co_alarm_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="nabu_casa_zwa2") +def nabu_casa_zwa2_fixture( + client: MagicMock, nabu_casa_zwa2_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2.""" + node = Node(client, nabu_casa_zwa2_state) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="nabu_casa_zwa2_legacy") +def nabu_casa_zwa2_legacy_fixture( + client: MagicMock, nabu_casa_zwa2_legacy_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2 (legacy firmware).""" + node = Node(client, nabu_casa_zwa2_legacy_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json new file mode 100644 index 00000000000..dd580a9b43b --- /dev/null +++ b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json @@ -0,0 +1,1116 @@ +{ + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 99, + "productId": 13619, + "productType": 18756, + "firmwareVersion": "1.26.1", + "zwavePlusVersion": 2, + "name": "zwa4013_fan", + "deviceConfig": { + "manufacturer": "Enbrighten", + "manufacturerId": 99, + "label": "58446 / ZWA4013", + "description": "In-Wall Fan Speed Control, QFSW, 700S", + "devices": [ + { + "productType": 18756, + "productId": 13619 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "mapBasicSet": "event" + }, + "metadata": { + "inclusion": "1. Follow the instructions for your Z-Wave certified Controller to add a device to the Z-Wave network.\n2. Once the controller is ready to add your device, press the top of bottom of the wireless smart Fan controller", + "exclusion": "1. Follow the instructions for your Z-Wave certified controller to remove a device from the Z-wave network\n2. Once the controller is ready to remove your device, press the top or bottom of the wireless smart Fan controller", + "reset": "Pull the airgap switch. Press and hold the bottom button, push the airgap switch in and continue holding the bottom button for 10 seconds. The LED will flash once each of the 8 colors then stop" + } + }, + "label": "58446 / ZWA4013", + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0063:0x4944:0x3533:1.26.1", + "statistics": { + "commandsTX": 158, + "commandsRX": 154, + "commandsDroppedRX": 2, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 30.1, + "lastSeen": "2025-07-05T19:10:23.100Z", + "lwr": { + "repeaters": [], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-07-05T19:10:23.100Z", + "protocol": 0, + "sdkVersion": "7.18.1", + "values": [ + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "event", + "propertyName": "event", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Event value", + "min": 0, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "LED Indicator", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "On when load is off", + "1": "On when load is on", + "2": "Always off", + "3": "Always on" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Inverted Orientation", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inverted Orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "3-Way Setup", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "3-Way Setup", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Add-on", + "1": "Standard" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Alternate Exclusion", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Press MENU button once", + "label": "Alternate Exclusion", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyName": "LED Indicator Color", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Color", + "default": 5, + "min": 1, + "max": 8, + "states": { + "1": "Red", + "2": "Orange", + "3": "Yellow", + "4": "Green", + "5": "Blue", + "6": "Pink", + "7": "Purple", + "8": "White" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "LED Indicator Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Guidelight Mode Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Guidelight Mode Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyName": "State After Power Failure", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "State After Power Failure", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Always off", + "1": "Previous state" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Fan Speed Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Fan Speed Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Press and hold", + "1": "Single button presses" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 84, + "propertyName": "Reset to Factory Default", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Reset to Factory Default", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 13619 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 18756 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.26"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.26.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x50 (Node Identify) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x50 (Node Identify) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x50 (Node Identify) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json b/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json new file mode 100644 index 00000000000..6fc7a89046e --- /dev/null +++ b/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json @@ -0,0 +1,447 @@ +{ + "nodeId": 30, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 132, + "productId": 265, + "productType": 785, + "firmwareVersion": "1.9", + "deviceConfig": { + "filename": "/data/db/devices/0x0084/ssa1_ssa2.json", + "isEmbedded": true, + "manufacturer": "FortrezZ LLC", + "manufacturerId": 132, + "label": "SSA1/SSA2", + "description": "Siren and Strobe Alarm", + "devices": [ + { + "productType": 785, + "productId": 267 + }, + { + "productType": 787, + "productId": 264 + }, + { + "productType": 787, + "productId": 267 + }, + { + "productType": 785, + "productId": 265 + }, + { + "productType": 787, + "productId": 265 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + } + }, + "label": "SSA1/SSA2", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0084:0x0311:0x0109:1.9", + "statistics": { + "commandsTX": 23, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 22, + "rtt": 50.7, + "lastSeen": "2025-06-10T03:23:40.329Z" + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-06-04T08:00:19.437Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Delay Before Accept of Basic Set Off", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay, from the time the siren-strobe turns on", + "label": "Delay Before Accept of Basic Set Off", + "default": 0, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 265 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 785 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 132 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.9"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "2.97" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + } + ], + "endpoints": [ + { + "nodeId": 30, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json new file mode 100644 index 00000000000..8ea8cdbd009 --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json @@ -0,0 +1,231 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/data/db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "NC-ZWA-9734", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 227 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 181 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 255, + "green": 227, + "blue": 181 + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 0, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "ffe3b5" + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json new file mode 100644 index 00000000000..e0c57462440 --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json @@ -0,0 +1,146 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/home/dominic/Repositories/zwavejs2mqtt/store/.config-db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "NC-ZWA-9734", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 3f1f9b737bd..0b83d08072c 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS Websocket API.""" +import asyncio from copy import deepcopy from http import HTTPStatus from io import BytesIO @@ -2520,7 +2521,7 @@ async def test_subscribe_rebuild_routes_progress( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) @@ -2564,7 +2565,7 @@ async def test_subscribe_rebuild_routes_progress_initial_value( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) @@ -5109,17 +5110,12 @@ async def test_hard_reset_controller( ws_client = await hass_ws_client(hass) assert entry.unique_id == "3245146787" - async def async_send_command_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" + async def mock_driver_hard_reset() -> None: client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) - return {} - client.async_send_command.side_effect = async_send_command_driver_ready + client.driver.async_hard_reset = AsyncMock(side_effect=mock_driver_hard_reset) await ws_client.send_json_auto_id( { @@ -5128,6 +5124,7 @@ async def test_hard_reset_controller( } ) msg = await ws_client.receive_json() + await hass.async_block_till_done() device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} @@ -5135,16 +5132,10 @@ async def test_hard_reset_controller( assert device is not None assert msg["result"] == device.id assert msg["success"] - - assert client.async_send_command.call_count == 3 - # The first call is the relevant hard reset command. - # 25 is the require_schema parameter. - assert client.async_send_command.call_args_list[0] == call( - {"command": "driver.hard_reset"}, 25 - ) + assert client.driver.async_hard_reset.call_count == 1 assert entry.unique_id == "1234" - client.async_send_command.reset_mock() + client.driver.async_hard_reset.reset_mock() # Test client connect error when getting the server version. @@ -5158,6 +5149,7 @@ async def test_hard_reset_controller( ) msg = await ws_client.receive_json() + await hass.async_block_till_done() device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} @@ -5165,33 +5157,24 @@ async def test_hard_reset_controller( assert device is not None assert msg["result"] == device.id assert msg["success"] - - assert client.async_send_command.call_count == 3 - # The first call is the relevant hard reset command. - # 25 is the require_schema parameter. - assert client.async_send_command.call_args_list[0] == call( - {"command": "driver.hard_reset"}, 25 - ) + assert client.driver.async_hard_reset.call_count == 1 assert ( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller reset" ) in caplog.text - client.async_send_command.reset_mock() + client.driver.async_hard_reset.reset_mock() + get_server_version.side_effect = None # Test sending command with driver not ready and timeout. - async def async_send_command_no_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" - return {} + async def mock_driver_hard_reset_no_driver_ready() -> None: + pass - client.async_send_command.side_effect = async_send_command_no_driver_ready + client.driver.async_hard_reset.side_effect = mock_driver_hard_reset_no_driver_ready with patch( - "homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", + "homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT", new=0, ): await ws_client.send_json_auto_id( @@ -5201,6 +5184,7 @@ async def test_hard_reset_controller( } ) msg = await ws_client.receive_json() + await hass.async_block_till_done() device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} @@ -5208,32 +5192,29 @@ async def test_hard_reset_controller( assert device is not None assert msg["result"] == device.id assert msg["success"] + assert client.driver.async_hard_reset.call_count == 1 - assert client.async_send_command.call_count == 3 - # The first call is the relevant hard reset command. - # 25 is the require_schema parameter. - assert client.async_send_command.call_args_list[0] == call( - {"command": "driver.hard_reset"}, 25 - ) - - client.async_send_command.reset_mock() + client.driver.async_hard_reset.reset_mock() # Test FailedZWaveCommand is caught - with patch( - "zwave_js_server.model.driver.Driver.async_hard_reset", - side_effect=FailedZWaveCommand("failed_command", 1, "error message"), - ): - await ws_client.send_json_auto_id( - { - TYPE: "zwave_js/hard_reset_controller", - ENTRY_ID: entry.entry_id, - } - ) - msg = await ws_client.receive_json() + client.driver.async_hard_reset.side_effect = FailedZWaveCommand( + "failed_command", 1, "error message" + ) - assert not msg["success"] - assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + assert client.driver.async_hard_reset.call_count == 1 + + client.driver.async_hard_reset.side_effect = None # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -5578,17 +5559,24 @@ async def test_restore_nvm( # Set up mocks for the controller events controller = client.driver.controller - async def async_send_command_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" + async def mock_restore_nvm_base64( + self, base64_data: str, options: dict[str, bool] | None = None + ) -> None: + controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 150, "total": 200}, + ) + controller.data["homeId"] = 3245146787 client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) - return {} - client.async_send_command.side_effect = async_send_command_driver_ready + controller.async_restore_nvm_base64 = AsyncMock(side_effect=mock_restore_nvm_base64) # Send the subscription request await ws_client.send_json_auto_id( @@ -5599,7 +5587,19 @@ async def test_restore_nvm( } ) - # Verify the finished event first + # Verify the convert progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm convert progress" + assert msg["event"]["bytesRead"] == 100 + assert msg["event"]["total"] == 200 + + # Verify the restore progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 150 + assert msg["event"]["total"] == 200 + + # Verify the finished event msg = await ws_client.receive_json() assert msg["type"] == "event" assert msg["event"]["event"] == "finished" @@ -5609,52 +5609,18 @@ async def test_restore_nvm( assert msg["type"] == "result" assert msg["success"] is True - # Simulate progress events - event = Event( - "nvm restore progress", - { - "source": "controller", - "event": "nvm restore progress", - "bytesWritten": 25, - "total": 100, - }, - ) - controller.receive_event(event) - msg = await ws_client.receive_json() - assert msg["event"]["event"] == "nvm restore progress" - assert msg["event"]["bytesWritten"] == 25 - assert msg["event"]["total"] == 100 - - event = Event( - "nvm restore progress", - { - "source": "controller", - "event": "nvm restore progress", - "bytesWritten": 50, - "total": 100, - }, - ) - controller.receive_event(event) - msg = await ws_client.receive_json() - assert msg["event"]["event"] == "nvm restore progress" - assert msg["event"]["bytesWritten"] == 50 - assert msg["event"]["total"] == 100 - await hass.async_block_till_done() # Verify the restore was called # The first call is the relevant one for nvm restore. - assert client.async_send_command.call_count == 3 - assert client.async_send_command.call_args_list[0] == call( - { - "command": "controller.restore_nvm", - "nvmData": "dGVzdA==", - }, - require_schema=14, + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, ) assert entry.unique_id == "1234" - client.async_send_command.reset_mock() + controller.async_restore_nvm_base64.reset_mock() # Test client connect error when getting the server version. @@ -5669,7 +5635,19 @@ async def test_restore_nvm( } ) - # Verify the finished event first + # Verify the convert progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm convert progress" + assert msg["event"]["bytesRead"] == 100 + assert msg["event"]["total"] == 200 + + # Verify the restore progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 150 + assert msg["event"]["total"] == 200 + + # Verify the finished event msg = await ws_client.receive_json() assert msg["type"] == "event" assert msg["event"]["event"] == "finished" @@ -5679,46 +5657,46 @@ async def test_restore_nvm( assert msg["type"] == "result" assert msg["success"] is True - assert client.async_send_command.call_count == 3 - assert client.async_send_command.call_args_list[0] == call( - { - "command": "controller.restore_nvm", - "nvmData": "dGVzdA==", - }, - require_schema=14, + await hass.async_block_till_done() + + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, ) assert ( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller NVM restore" ) in caplog.text - client.async_send_command.reset_mock() + controller.async_restore_nvm_base64.reset_mock() + get_server_version.side_effect = None - # Test sending command with driver not ready and timeout. + # Test sending command without driver ready event causing timeout. - async def async_send_command_no_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" - return {} + async def mock_restore_nvm_without_driver_ready( + data: bytes, options: dict[str, bool] | None = None + ): + controller.data["homeId"] = 3245146787 - client.async_send_command.side_effect = async_send_command_no_driver_ready + controller.async_restore_nvm_base64.side_effect = ( + mock_restore_nvm_without_driver_ready + ) with patch( - "homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", + "homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT", new=0, ): # Send the subscription request await ws_client.send_json_auto_id( { "type": "zwave_js/restore_nvm", - "entry_id": integration.entry_id, + "entry_id": entry.entry_id, "data": "dGVzdA==", # base64 encoded "test" } ) - # Verify the finished event first + # Verify the finished event msg = await ws_client.receive_json() assert msg["type"] == "event" @@ -5732,36 +5710,41 @@ async def test_restore_nvm( await hass.async_block_till_done() # Verify the restore was called - # The first call is the relevant one for nvm restore. - assert client.async_send_command.call_count == 3 - assert client.async_send_command.call_args_list[0] == call( - { - "command": "controller.restore_nvm", - "nvmData": "dGVzdA==", - }, - require_schema=14, + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, ) - client.async_send_command.reset_mock() + controller.async_restore_nvm_base64.reset_mock() # Test restore failure - with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_restore_nvm_base64", - side_effect=FailedZWaveCommand("failed_command", 1, "error message"), - ): - # Send the subscription request - await ws_client.send_json_auto_id( - { - "type": "zwave_js/restore_nvm", - "entry_id": integration.entry_id, - "data": "dGVzdA==", # base64 encoded "test" - } - ) + controller.async_restore_nvm_base64.side_effect = FailedZWaveCommand( + "failed_command", 1, "error message" + ) - # Verify error response - msg = await ws_client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "zwave_error" + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": entry.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + + await hass.async_block_till_done() + + # Verify the restore was called + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, + ) # Test entry_id not found await ws_client.send_json_auto_id( @@ -5776,13 +5759,13 @@ async def test_restore_nvm( assert msg["error"]["code"] == "not_found" # Test config entry not loaded - await hass.config_entries.async_unload(integration.entry_id) + await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json_auto_id( { "type": "zwave_js/restore_nvm", - "entry_id": integration.entry_id, + "entry_id": entry.entry_id, "data": "dGVzdA==", # base64 encoded "test" } ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index dd8838e0775..52b840fb690 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -29,12 +29,6 @@ from homeassistant.components.zwave_js.const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, - CONF_LR_S2_ACCESS_CONTROL_KEY, - CONF_LR_S2_AUTHENTICATED_KEY, - CONF_S0_LEGACY_KEY, - CONF_S2_ACCESS_CONTROL_KEY, - CONF_S2_AUTHENTICATED_KEY, - CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, DOMAIN, ) @@ -687,7 +681,17 @@ async def test_usb_discovery( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -778,9 +782,18 @@ async def test_usb_discovery_addon_not_running( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" - # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] assert data_schema is not None assert data_schema({}) == { @@ -867,14 +880,12 @@ async def test_usb_discovery_migration( get_server_version: AsyncMock, ) -> None: """Test usb discovery migration.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -882,6 +893,11 @@ async def test_usb_discovery_migration( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -893,14 +909,7 @@ async def test_usb_discovery_migration( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -910,6 +919,7 @@ async def test_usb_discovery_migration( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) @@ -927,8 +937,9 @@ async def test_usb_discovery_migration( ) assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" + assert result["step_id"] == "confirm_usb_migration" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -947,7 +958,6 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" - assert entry.unique_id == "4321" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -962,7 +972,8 @@ async def test_usb_discovery_migration( assert restart_addon.call_args == call("core_zwave_js") - version_info.home_id = 5678 + version_info = get_server_version.return_value + version_info.home_id = 3245146787 result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -986,7 +997,7 @@ async def test_usb_discovery_migration( assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["use_addon"] is True assert "keep_old_devices" not in entry.data - assert entry.unique_id == "5678" + assert entry.unique_id == "3245146787" @pytest.mark.usefixtures("supervisor", "addon_running") @@ -1003,9 +1014,9 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -1013,6 +1024,11 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -1024,14 +1040,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -1041,6 +1050,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) @@ -1055,8 +1065,9 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( ) assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" + assert result["step_id"] == "confirm_usb_migration" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -1090,7 +1101,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert restart_addon.call_args == call("core_zwave_js") with patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), + ("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"), new=0, ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -1100,7 +1111,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 4 + assert client.connect.call_count == 3 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -1114,7 +1125,8 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert entry.unique_id == "1234" + assert "keep_old_devices" in entry.data @pytest.mark.usefixtures("supervisor", "addon_installed") @@ -1150,6 +1162,25 @@ async def test_discovery_addon_not_running( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1250,6 +1281,25 @@ async def test_discovery_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1752,6 +1802,25 @@ async def test_addon_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1846,6 +1915,25 @@ async def test_addon_installed_start_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1935,6 +2023,25 @@ async def test_addon_installed_failures( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2005,6 +2112,25 @@ async def test_addon_installed_set_options_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2115,6 +2241,25 @@ async def test_addon_installed_already_configured( result["flow_id"], { "usb_path": "/new", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2202,6 +2347,25 @@ async def test_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2860,6 +3024,7 @@ async def test_reconfigure_different_device( entry = integration data = {**entry.data, **entry_data} hass.config_entries.async_update_entry(entry, data=data, unique_id="1234") + client.driver.controller.data["homeId"] = 1234 assert entry.data["url"] == "ws://test.org" @@ -3013,6 +3178,7 @@ async def test_reconfigure_addon_restart_failed( entry = integration data = {**entry.data, **entry_data} hass.config_entries.async_update_entry(entry, data=data, unique_id="1234") + client.driver.controller.data["homeId"] = 1234 assert entry.data["url"] == "ws://test.org" @@ -3401,21 +3567,14 @@ async def test_reconfigure_migrate_low_sdk_version( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( - "reset_server_version_side_effect", - "reset_unique_id", "restore_server_version_side_effect", "final_unique_id", + "keep_old_devices", + "device_entry_count", ), [ - (None, "4321", None, "3245146787"), - (aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"), - (None, "4321", aiohttp.ClientError("Boom"), "5678"), - ( - aiohttp.ClientError("Boom"), - "3245146787", - aiohttp.ClientError("Boom"), - "5678", - ), + (None, "3245146787", False, 2), + (aiohttp.ClientError("Boom"), "5678", True, 4), ], ) async def test_reconfigure_migrate_with_addon( @@ -3428,18 +3587,17 @@ async def test_reconfigure_migrate_with_addon( addon_options: dict[str, Any], set_addon_options: AsyncMock, get_server_version: AsyncMock, - reset_server_version_side_effect: Exception | None, - reset_unique_id: str, restore_server_version_side_effect: Exception | None, final_unique_id: str, + keep_old_devices: bool, + device_entry_count: int, ) -> None: """Test migration flow with add-on.""" - get_server_version.side_effect = reset_server_version_side_effect version_info = get_server_version.return_value - version_info.home_id = 4321 entry = integration assert client.connect.call_count == 1 assert client.driver.controller.home_id == 3245146787 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, data={ @@ -3494,14 +3652,7 @@ async def test_reconfigure_migrate_with_addon( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3531,11 +3682,6 @@ async def test_reconfigure_migrate_with_addon( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3552,7 +3698,6 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == reset_unique_id result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -3565,8 +3710,6 @@ async def test_reconfigure_migrate_with_addon( with pytest.raises(InInvalid): data_schema.schema[CONF_USB_PATH](addon_options["device"]) - # Reset side effect before starting the add-on. - get_server_version.side_effect = None version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure( @@ -3622,10 +3765,10 @@ async def test_reconfigure_migrate_with_addon( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert ("keep_old_devices" in entry.data) is keep_old_devices assert entry.unique_id == final_unique_id - assert len(device_registry.devices) == 2 + assert len(device_registry.devices) == device_entry_count controller_device_id_ext = ( f"{controller_device_id}-{controller_node.manufacturer_id}:" f"{controller_node.product_type}:{controller_node.product_id}" @@ -3646,156 +3789,6 @@ async def test_reconfigure_migrate_with_addon( assert client.driver.controller.home_id == 3245146787 -@pytest.mark.usefixtures("supervisor", "addon_running") -async def test_reconfigure_migrate_reset_driver_ready_timeout( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, - restart_addon: AsyncMock, - set_addon_options: AsyncMock, - get_server_version: AsyncMock, -) -> None: - """Test migration flow with driver ready timeout after controller reset.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 - entry = integration - assert client.connect.call_count == 1 - hass.config_entries.async_update_entry( - entry, - unique_id="1234", - data={ - "url": "ws://localhost:3000", - "use_addon": True, - "usb_path": "/dev/ttyUSB0", - }, - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - - async def mock_reset_controller(): - await asyncio.sleep(0) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes): - client.driver.controller.emit( - "nvm convert progress", - {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, - ) - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm restore progress", - {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, - ) - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) - - events = async_capture_events( - hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - with ( - patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), - new=0, - ), - patch("pathlib.Path.write_bytes") as mock_file, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - assert len(events) == 1 - assert events[0].data["progress"] == 0.5 - events.clear() - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "instruct_unplug" - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == "4321" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "choose_serial_port" - data_schema = result["data_schema"] - assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USB_PATH: "/test", - }, - ) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_addon" - assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": "/test"}) - ) - - await hass.async_block_till_done() - - assert restart_addon.call_args == call("core_zwave_js") - - version_info.home_id = 5678 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "restore_nvm" - assert client.connect.call_count == 2 - - await hass.async_block_till_done() - assert client.connect.call_count == 4 - assert entry.state is config_entries.ConfigEntryState.LOADED - assert client.driver.controller.async_restore_nvm.call_count == 1 - assert len(events) == 2 - assert events[0].data["progress"] == 0.25 - assert events[1].data["progress"] == 0.75 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "migration_successful" - assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test" - assert entry.data["use_addon"] is True - assert entry.unique_id == "5678" - assert "keep_old_devices" not in entry.data - - @pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_restore_driver_ready_timeout( hass: HomeAssistant, @@ -3807,9 +3800,10 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( """Test migration flow with driver ready timeout after nvm restore.""" entry = integration assert client.connect.call_count == 1 + assert client.driver.controller.home_id == 3245146787 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -3817,6 +3811,11 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -3828,14 +3827,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3845,6 +3837,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) @@ -3861,11 +3854,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3909,7 +3897,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert restart_addon.call_args == call("core_zwave_js") with patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), + ("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"), new=0, ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -3919,7 +3907,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 4 + assert client.connect.call_count == 3 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -3933,7 +3921,8 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert "keep_old_devices" in entry.data + assert entry.unique_id == "1234" async def test_reconfigure_migrate_backup_failure( @@ -3960,11 +3949,6 @@ async def test_reconfigure_migrate_backup_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" assert "keep_old_devices" not in entry.data @@ -3998,11 +3982,6 @@ async def test_reconfigure_migrate_backup_file_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4040,13 +4019,6 @@ async def test_reconfigure_migrate_start_addon_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4056,11 +4028,6 @@ async def test_reconfigure_migrate_start_addon_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4124,12 +4091,6 @@ async def test_reconfigure_migrate_restore_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) client.driver.controller.async_restore_nvm = AsyncMock( side_effect=FailedCommand("test_error", "unknown_error") ) @@ -4143,11 +4104,6 @@ async def test_reconfigure_migrate_restore_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4242,106 +4198,6 @@ async def test_get_driver_failure_intent_migrate( assert "keep_old_devices" not in entry.data -async def test_get_driver_failure_instruct_unplug( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test get driver failure in instruct unplug step.""" - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "config_entry_not_loaded" - - -async def test_hard_reset_failure( - hass: HomeAssistant, - integration: MockConfigEntry, - client: MagicMock, -) -> None: - """Test hard reset failure.""" - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - client.driver.async_hard_reset = AsyncMock( - side_effect=FailedCommand("test_error", "unknown_error") - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reset_failed" - - async def test_choose_serial_port_usb_ports_failure( hass: HomeAssistant, integration: MockConfigEntry, @@ -4361,13 +4217,6 @@ async def test_choose_serial_port_usb_ports_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4377,11 +4226,6 @@ async def test_choose_serial_port_usb_ports_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4435,8 +4279,8 @@ async def test_configure_addon_usb_ports_failure( assert result["reason"] == "usb_ports_failed" -async def test_get_usb_ports_sorting() -> None: - """Test that get_usb_ports sorts ports with 'n/a' descriptions last.""" +async def test_get_usb_ports_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions when other ports are available.""" mock_ports = [ ListPortInfo("/dev/ttyUSB0"), ListPortInfo("/dev/ttyUSB1"), @@ -4453,13 +4297,105 @@ async def test_get_usb_ports_sorting() -> None: descriptions = list(result.values()) - # Verify that descriptions containing "n/a" are at the end - + # Verify that only non-"n/a" descriptions are returned assert descriptions == [ "Device A - /dev/ttyUSB1, s/n: n/a", "Device B - /dev/ttyUSB3, s/n: n/a", + ] + + +async def test_get_usb_ports_all_na() -> None: + """Test that get_usb_ports returns all ports as-is when only 'n/a' descriptions exist.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "N/A" + mock_ports[2].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that all ports are returned since they all have "n/a" descriptions + assert len(descriptions) == 3 + # Verify that all descriptions contain "n/a" (case-insensitive) + assert all("n/a" in desc.lower() for desc in descriptions) + # Verify that all expected device paths are present + device_paths = [desc.split(" - ")[1].split(",")[0] for desc in descriptions] + assert "/dev/ttyUSB0" in device_paths + assert "/dev/ttyUSB1" in device_paths + assert "/dev/ttyUSB2" in device_paths + + +async def test_get_usb_ports_mixed_case_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions with different case variations.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ListPortInfo("/dev/ttyUSB3"), + ListPortInfo("/dev/ttyUSB4"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "Device A" + mock_ports[2].description = "N/A" + mock_ports[3].description = "n/A" + mock_ports[4].description = "Device B" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that only non-"n/a" descriptions are returned (case-insensitive filtering) + assert descriptions == [ + "Device A - /dev/ttyUSB1, s/n: n/a", + "Device B - /dev/ttyUSB4, s/n: n/a", + ] + + +async def test_get_usb_ports_empty_list() -> None: + """Test that get_usb_ports handles empty port list.""" + with patch("serial.tools.list_ports.comports", return_value=[]): + result = get_usb_ports() + + # Verify that empty dict is returned + assert result == {} + + +async def test_get_usb_ports_single_na_port() -> None: + """Test that get_usb_ports returns single 'n/a' port when it's the only one available.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single "n/a" port is returned + assert descriptions == [ "n/a - /dev/ttyUSB0, s/n: n/a", - "N/A - /dev/ttyUSB2, s/n: n/a", + ] + + +async def test_get_usb_ports_single_valid_port() -> None: + """Test that get_usb_ports returns single valid port.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "Device A" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single valid port is returned + assert descriptions == [ + "Device A - /dev/ttyUSB0, s/n: n/a", ] @@ -4496,13 +4432,8 @@ async def test_intent_recommended_user( assert result["step_id"] == "configure_addon_user" data_schema = result["data_schema"] assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] is not None - assert data_schema.schema.get(CONF_S0_LEGACY_KEY) is None - assert data_schema.schema.get(CONF_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_S2_AUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_S2_UNAUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_AUTHENTICATED_KEY) is None + assert len(data_schema.schema) == 1 + assert data_schema.schema.get(CONF_USB_PATH) is not None result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index 46686be0994..fec75d1c032 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -549,7 +549,14 @@ async def test_get_action_capabilities( assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ) == [{"type": "boolean", "name": "refresh_all_values", "optional": True}] + ) == [ + { + "type": "boolean", + "name": "refresh_all_values", + "optional": True, + "required": False, + } + ] # Test ping capabilities = await device_action.async_get_action_capabilities( @@ -608,10 +615,15 @@ async def test_get_action_capabilities( "type": "select", }, {"name": "property", "required": True, "type": "string"}, - {"name": "property_key", "optional": True, "type": "string"}, - {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "property_key", "optional": True, "required": False, "type": "string"}, + {"name": "endpoint", "optional": True, "required": False, "type": "string"}, {"name": "value", "required": True, "type": "string"}, - {"type": "boolean", "name": "wait_for_result", "optional": True}, + { + "type": "boolean", + "name": "wait_for_result", + "optional": True, + "required": False, + }, ] # Test enumerated type param @@ -771,7 +783,7 @@ async def test_get_action_capabilities_meter_triggers( assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer - ) == [{"type": "string", "name": "value", "optional": True}] + ) == [{"type": "string", "name": "value", "optional": True, "required": False}] async def test_failure_scenarios( diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 17bc4cf0f5d..123191e1f3a 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -511,8 +511,8 @@ async def test_get_condition_capabilities_value( "type": "select", }, {"name": "property", "required": True, "type": "string"}, - {"name": "property_key", "optional": True, "type": "string"}, - {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "property_key", "optional": True, "required": False, "type": "string"}, + {"name": "endpoint", "optional": True, "required": False, "type": "string"}, {"name": "value", "required": True, "type": "string"}, ] diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index ccc69f7723d..7ff76888ce4 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -201,10 +201,15 @@ async def test_get_trigger_capabilities_notification_notification( capabilities["extra_fields"], custom_serializer=cv.custom_serializer ) == unordered( [ - {"name": "type.", "optional": True, "type": "string"}, - {"name": "label", "optional": True, "type": "string"}, - {"name": "event", "optional": True, "type": "string"}, - {"name": "event_label", "optional": True, "type": "string"}, + {"name": "type.", "optional": True, "required": False, "type": "string"}, + {"name": "label", "optional": True, "required": False, "type": "string"}, + {"name": "event", "optional": True, "required": False, "type": "string"}, + { + "name": "event_label", + "optional": True, + "required": False, + "type": "string", + }, ] ) @@ -336,8 +341,18 @@ async def test_get_trigger_capabilities_entry_control_notification( capabilities["extra_fields"], custom_serializer=cv.custom_serializer ) == unordered( [ - {"name": "event_type", "optional": True, "type": "string"}, - {"name": "data_type", "optional": True, "type": "string"}, + { + "name": "event_type", + "optional": True, + "required": False, + "type": "string", + }, + { + "name": "data_type", + "optional": True, + "required": False, + "type": "string", + }, ] ) @@ -580,6 +595,7 @@ async def test_get_trigger_capabilities_node_status( { "name": "from", "optional": True, + "required": False, "options": [ ("asleep", "asleep"), ("awake", "awake"), @@ -591,6 +607,7 @@ async def test_get_trigger_capabilities_node_status( { "name": "to", "optional": True, + "required": False, "options": [ ("asleep", "asleep"), ("awake", "awake"), @@ -599,7 +616,12 @@ async def test_get_trigger_capabilities_node_status( ], "type": "select", }, - {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + { + "name": "for", + "optional": True, + "required": False, + "type": "positive_time_period_dict", + }, ] @@ -781,6 +803,7 @@ async def test_get_trigger_capabilities_basic_value_notification( { "name": "value", "optional": True, + "required": False, "type": "integer", "valueMin": 0, "valueMax": 255, @@ -972,6 +995,7 @@ async def test_get_trigger_capabilities_central_scene_value_notification( { "name": "value", "optional": True, + "required": False, "type": "select", "options": [(0, "KeyPressed"), (1, "KeyReleased"), (2, "KeyHeldDown")], }, @@ -1156,6 +1180,7 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( { "name": "value", "optional": True, + "required": False, "type": "integer", "valueMin": 1, "valueMax": 255, @@ -1406,10 +1431,10 @@ async def test_get_trigger_capabilities_value_updated_value( ], }, {"name": "property", "required": True, "type": "string"}, - {"name": "property_key", "optional": True, "type": "string"}, - {"name": "endpoint", "optional": True, "type": "string"}, - {"name": "from", "optional": True, "type": "string"}, - {"name": "to", "optional": True, "type": "string"}, + {"name": "property_key", "optional": True, "required": False, "type": "string"}, + {"name": "endpoint", "optional": True, "required": False, "type": "string"}, + {"name": "from", "optional": True, "required": False, "type": "string"}, + {"name": "to", "optional": True, "required": False, "type": "string"}, ] @@ -1552,6 +1577,7 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_range( { "name": "from", "optional": True, + "required": False, "valueMin": 0, "valueMax": 255, "type": "integer", @@ -1559,6 +1585,7 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_range( { "name": "to", "optional": True, + "required": False, "valueMin": 0, "valueMax": 255, "type": "integer", @@ -1600,12 +1627,14 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_enumerate { "name": "from", "optional": True, + "required": False, "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], "type": "select", }, { "name": "to", "optional": True, + "required": False, "options": [(0, "Disable Beeper"), (255, "Enable Beeper")], "type": "select", }, diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index c8bfca2b35f..299c003aefe 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -28,7 +28,13 @@ from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_UNKNOWN, + EntityCategory, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -98,6 +104,20 @@ async def test_ge_12730(hass: HomeAssistant, client, ge_12730, integration) -> N assert state +async def test_enbrighten_58446_zwa4013( + hass: HomeAssistant, client, enbrighten_58446_zwa4013, integration +) -> None: + """Test GE 12730 Fan Controller v2.0 multilevel switch is discovered as a fan.""" + node = enbrighten_58446_zwa4013 + assert node.device_class.specific.label == "Multilevel Power Switch" + + state = hass.states.get("light.zwa4013_fan") + assert not state + + state = hass.states.get("fan.zwa4013_fan") + assert state + + async def test_inovelli_lzw36( hass: HomeAssistant, client, inovelli_lzw36, integration ) -> None: @@ -137,6 +157,16 @@ async def test_lock_popp_electric_strike_lock_control( assert hass.states.get("select.node_62_current_lock_mode") is not None +async def test_fortrez_ssa2_siren( + hass: HomeAssistant, + client: MagicMock, + fortrezz_ssa2_siren: Node, + integration: MockConfigEntry, +) -> None: + """Test Fortrezz SSA2 siren gets discovered correctly.""" + assert hass.states.get("select.siren_and_strobe_alarm") is not None + + async def test_fortrez_ssa3_siren( hass: HomeAssistant, client, fortrezz_ssa3_siren, integration ) -> None: @@ -239,6 +269,7 @@ async def test_merten_507801_disabled_enitites( assert updated_entry.disabled is False +@pytest.mark.parametrize("platforms", [[Platform.BUTTON, Platform.NUMBER]]) async def test_zooz_zen72( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -310,6 +341,9 @@ async def test_zooz_zen72( assert args["value"] is True +@pytest.mark.parametrize( + "platforms", [[Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]] +) async def test_indicator_test( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -481,3 +515,67 @@ async def test_aeotec_smart_switch_7( entity_entry = entity_registry.async_get(state.entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG + + +async def test_nabu_casa_zwa2( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery.""" + state = hass.states.get("light.home_assistant_connect_zwa_2_led") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.ONOFF, + ], "The LED indicator should be an ON/OFF light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) + + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + ) + + +async def test_nabu_casa_zwa2_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2_legacy: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery with legacy firmware.""" + state = hass.states.get("light.home_assistant_connect_zwa_2_led") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.HS, + ], "The LED indicator should be a color light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) + + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + ) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4350d7f7649..1aaa9013d87 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -37,7 +37,11 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY +from .common import ( + AIR_TEMPERATURE_SENSOR, + BULB_6_MULTI_COLOR_LIGHT_ENTITY, + EATON_RF9640_ENTITY, +) from tests.common import ( MockConfigEntry, @@ -192,19 +196,24 @@ async def test_listen_done_during_setup_before_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, ) -> None: """Test listen task finishing during setup before forward entry.""" + listen_result = asyncio.Future[None]() assert hass.state is CoreState.running + async def connect(): + await asyncio.sleep(0) + client.connected = True + async def listen(driver_ready: asyncio.Event) -> None: await listen_block.wait() await listen_result async_fire_time_changed(hass, fire_all=True) + client.connect.side_effect = connect client.listen.side_effect = listen hass.set_state(core_state) listen_block.set() @@ -225,9 +234,9 @@ async def test_not_connected_during_setup_after_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], ) -> None: """Test we handle not connected client during setup after forward entry.""" + listen_result = asyncio.Future[None]() async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: """Mock send command.""" @@ -273,12 +282,12 @@ async def test_listen_done_during_setup_after_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, ) -> None: """Test listen task finishing during setup after forward entry.""" + listen_result = asyncio.Future[None]() assert hass.state is CoreState.running original_send_command_side_effect = client.async_send_command.side_effect @@ -316,16 +325,14 @@ async def test_listen_done_during_setup_after_forward_entry( @pytest.mark.parametrize( - ("core_state", "final_config_entry_state", "disconnect_call_count"), + ("core_state", "disconnect_call_count"), [ ( CoreState.running, - ConfigEntryState.SETUP_RETRY, - 2, - ), # the reload will cause a disconnect call too + 1, + ), # the reload will cause a disconnect ( CoreState.stopping, - ConfigEntryState.LOADED, 0, ), # the home assistant stop event will handle the disconnect ], @@ -341,19 +348,33 @@ async def test_listen_done_during_setup_after_forward_entry( async def test_listen_done_after_setup( hass: HomeAssistant, client: MagicMock, - integration: MockConfigEntry, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, - final_config_entry_state: ConfigEntryState, disconnect_call_count: int, ) -> None: """Test listen task finishing after setup.""" - config_entry = integration - assert config_entry.state is ConfigEntryState.LOADED + listen_result = asyncio.Future[None]() + + async def listen(driver_ready: asyncio.Event) -> None: + driver_ready.set() + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + + config_entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.state is CoreState.running + assert config_entry.state is ConfigEntryState.LOADED assert client.disconnect.call_count == 0 hass.set_state(core_state) @@ -361,10 +382,51 @@ async def test_listen_done_after_setup( getattr(listen_result, listen_future_result_method)(listen_future_result) await hass.async_block_till_done() - assert config_entry.state is final_config_entry_state + assert config_entry.state is ConfigEntryState.LOADED assert client.disconnect.call_count == disconnect_call_count +async def test_listen_ending_before_cancelling_listen( + hass: HomeAssistant, + integration: MockConfigEntry, + listen_block: asyncio.Event, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test listen ending during unloading before cancelling the listen task.""" + config_entry = integration + + # We can't easily simulate the race condition where the listen task ends + # before getting cancelled by the config entry during unloading. + # Use mock_state to provoke the correct condition. + config_entry.mock_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None) + listen_block.set() + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.UNLOAD_IN_PROGRESS + assert not any(record.levelno == logging.ERROR for record in caplog.records) + + +async def test_listen_ending_unrecoverable_config_entry_state( + hass: HomeAssistant, + integration: MockConfigEntry, + listen_block: asyncio.Event, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test listen ending when the config entry has an unrecoverable state.""" + config_entry = integration + + with patch.object( + hass.config_entries, "async_unload_platforms", return_value=False + ): + await hass.config_entries.async_unload(config_entry.entry_id) + + listen_block.set() + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.FAILED_UNLOAD + assert "Disconnected from server. Cannot recover entry" in caplog.text + + @pytest.mark.usefixtures("client") @pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_new_entity_on_value_added( @@ -435,17 +497,17 @@ async def test_on_node_added_ready( ) -async def test_on_node_added_preprovisioned( +async def test_check_pre_provisioned_device_update_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: - """Test node added event with a preprovisioned device.""" + """Test check pre-provisioned device that should update the device.""" dsk = "test" node = Node(client, deepcopy(multisensor_6_state)) - device = device_registry.async_get_or_create( + pre_provisioned_device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={(DOMAIN, f"provision_{dsk}")}, ) @@ -453,7 +515,7 @@ async def test_on_node_added_preprovisioned( { "dsk": dsk, "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], - "device_id": device.id, + "device_id": pre_provisioned_device.id, } ) with patch( @@ -464,14 +526,60 @@ async def test_on_node_added_preprovisioned( client.driver.controller.emit("node added", event) await hass.async_block_till_done() - device = device_registry.async_get(device.id) + device = device_registry.async_get(pre_provisioned_device.id) assert device assert device.identifiers == { get_device_id(client.driver, node), get_device_id_ext(client.driver, node), } assert device.sw_version == node.firmware_version - # There should only be the controller and the preprovisioned device + # There should only be the controller and the pre-provisioned device + assert len(device_registry.devices) == 2 + + +async def test_check_pre_provisioned_device_remove_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test check pre-provisioned device that should remove the device.""" + dsk = "test" + driver = client.driver + node = Node(client, deepcopy(multisensor_6_state)) + pre_provisioned_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, f"provision_{dsk}")}, + ) + extended_identifier = get_device_id_ext(driver, node) + assert extended_identifier + existing_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={ + get_device_id(driver, node), + extended_identifier, + }, + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": dsk, + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": pre_provisioned_device.id, + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry", + side_effect=lambda id: provisioning_entry if id == node.node_id else None, + ): + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + assert not device_registry.async_get(pre_provisioned_device.id) + assert device_registry.async_get(existing_device.id) + + # There should only be the controller and the existing device assert len(device_registry.devices) == 2 @@ -510,8 +618,8 @@ async def test_on_node_added_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_ready( @@ -627,8 +735,8 @@ async def test_existing_node_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_not_replaced_when_not_ready( @@ -2066,12 +2174,8 @@ async def test_server_logging( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 2 - assert client.async_send_command.call_args_list[0][0][0] == { - "command": "controller.get_provisioning_entries", - } - assert client.async_send_command.call_args_list[1][0][0] == { - "command": "controller.get_provisioning_entry", - "dskOrNodeId": 1, + assert "driver.update_log_config" not in { + call[0][0]["command"] for call in client.async_send_command.call_args_list } assert not client.enable_server_logging.called assert not client.disable_server_logging.called @@ -2168,3 +2272,74 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] + + +async def test_entity_available_when_node_dead( + hass: HomeAssistant, client, bulb_6_multi_color, integration +) -> None: + """Test that entities remain available even when the node is dead.""" + + node = bulb_6_multi_color + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + + assert state + assert state.state != STATE_UNAVAILABLE + + # Send dead event to the node + event = Event( + "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should remain available even though the node is dead + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE + + # Send alive event to bring the node back + event = Event( + "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should still be available + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE + + +async def test_driver_ready_event( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test receiving a driver ready event.""" + config_entry = integration + assert config_entry.state is ConfigEntryState.LOADED + + config_entry_state_changes: list[ConfigEntryState] = [] + + def on_config_entry_state_change() -> None: + """Collect config entry state changes.""" + config_entry_state_changes.append(config_entry.state) + + config_entry.async_on_state_change(on_config_entry_state_change) + + driver_ready = Event( + type="driver ready", + data={ + "source": "driver", + "event": "driver ready", + }, + ) + + client.driver.receive_event(driver_ready) + await hass.async_block_till_done() + + assert len(config_entry_state_changes) == 4 + assert config_entry_state_changes[0] == ConfigEntryState.UNLOAD_IN_PROGRESS + assert config_entry_state_changes[1] == ConfigEntryState.NOT_LOADED + assert config_entry_state_changes[2] == ConfigEntryState.SETUP_IN_PROGRESS + assert config_entry_state_changes[3] == ConfigEntryState.LOADED diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 1011026ac68..9e36810872f 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -28,7 +28,7 @@ from homeassistant.components.zwave_js.lock import ( SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -295,7 +295,8 @@ async def test_door_lock( assert node.status == NodeStatus.DEAD state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) assert state - assert state.state == STATE_UNAVAILABLE + # The state should still be locked, even if the node is dead + assert state.state == LockState.LOCKED async def test_only_one_lock( diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d8c3de92b3b..d47fd771127 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -1,13 +1,14 @@ """Test the Z-Wave JS repairs module.""" from copy import deepcopy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.const import CONF_KEEP_OLD_DEVICES from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -34,7 +35,7 @@ async def _trigger_repair_issue( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) with patch( @@ -276,8 +277,12 @@ async def test_migrate_unique_id( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + client: MagicMock, + multisensor_6: Node, ) -> None: """Test the migrate unique id flow.""" + node = multisensor_6 old_unique_id = "123456789" config_entry = MockConfigEntry( domain=DOMAIN, @@ -289,8 +294,27 @@ async def test_migrate_unique_id( ) config_entry.add_to_hass(hass) + # Remove the node from the current controller's known nodes. + client.driver.controller.nodes.pop(node.node_id) + + # Create a device entry for the node connected to the old controller. + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{old_unique_id}-{node.node_id}")}, + name="Node connected to old controller", + ) + assert device_entry.name == "Node connected to old controller" + await hass.config_entries.async_setup(config_entry.entry_id) + assert CONF_KEEP_OLD_DEVICES in config_entry.data + assert config_entry.data[CONF_KEEP_OLD_DEVICES] is True + stored_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(stored_devices) == 2 + assert device_entry.id in {device.id for device in stored_devices} + await async_process_repairs_platforms(hass) ws_client = await hass_ws_client(hass) http_client = await hass_client() @@ -317,6 +341,13 @@ async def test_migrate_unique_id( # Apply fix data = await process_repair_fix_flow(http_client, flow_id) + await hass.async_block_till_done() + + stored_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(stored_devices) == 1 + assert device_entry.id not in {device.id for device in stored_devices} assert data["type"] == "create_entry" assert config_entry.unique_id == "3245146787" diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index ef77e22bbec..e287c9e988f 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -247,7 +247,7 @@ async def test_invalid_multilevel_sensor_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) @@ -610,7 +610,7 @@ async def test_invalid_meter_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) @@ -796,12 +796,14 @@ CONTROLLER_STATISTICS_SUFFIXES = { } # controller statistics with initial state of unknown CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { - "current_background_rssi_channel_0": -1, - "average_background_rssi_channel_0": -2, - "current_background_rssi_channel_1": -3, - "average_background_rssi_channel_1": -4, - "current_background_rssi_channel_2": STATE_UNKNOWN, - "average_background_rssi_channel_2": STATE_UNKNOWN, + "signal_noise_channel_0": -1, + "avg_signal_noise_channel_0": -2, + "signal_noise_channel_1": -3, + "avg_signal_noise_channel_1": -4, + "signal_noise_channel_2": -5, + "avg_signal_noise_channel_2": -6, + "signal_noise_channel_3": STATE_UNKNOWN, + "avg_signal_noise_channel_3": STATE_UNKNOWN, } NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" # node statistics with initial state of 0 @@ -815,7 +817,7 @@ NODE_STATISTICS_SUFFIXES = { # node statistics with initial state of unknown NODE_STATISTICS_SUFFIXES_UNKNOWN = { "round_trip_time": 6, - "rssi": 7, + "signal_strength": 7, } @@ -867,7 +869,7 @@ async def test_statistics_sensors_migration( ) -async def test_statistics_sensors_no_last_seen( +async def test_statistics_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, @@ -875,7 +877,7 @@ async def test_statistics_sensors_no_last_seen( integration, caplog: pytest.LogCaptureFixture, ) -> None: - """Test all statistics sensors but last seen which is enabled by default.""" + """Test statistics sensors.""" for prefix, suffixes in ( (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), @@ -885,7 +887,7 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -911,12 +913,12 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert not entry.disabled assert entry.disabled_by is None state = hass.states.get(entry.entity_id) - assert state + assert state, f"State for {entry.entity_id} not found" assert state.state == initial_state # Fire statistics updated for controller @@ -944,6 +946,10 @@ async def test_statistics_sensors_no_last_seen( "current": -3, "average": -4, }, + "channel2": { + "current": -5, + "average": -6, + }, "timestamp": 1681967176510, }, }, @@ -1023,13 +1029,199 @@ async def test_last_seen_statistics_sensors( entity_id = f"{NODE_STATISTICS_ENTITY_PREFIX}last_seen" entry = entity_registry.async_get(entity_id) assert entry - assert not entry.disabled + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state assert state.state == "2024-01-01T12:00:00+00:00" +async def test_rssi_sensor_error( + hass: HomeAssistant, + zp3111: Node, + integration: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test rssi sensor error.""" + entity_id = "sensor.4_in_1_sensor_signal_strength" + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + # Fire statistics updated event for node + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, # baseline + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "7" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 125, # no signal detected + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 127, # not available + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 126, # receiver saturated + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + ENERGY_PRODUCTION_ENTITY_MAP = { "energy_production_power": { "state": 1.23, diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 02675544644..7b00a9d0eef 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -977,7 +977,7 @@ async def test_zwave_js_event_invalid_config_entry_id( async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: """Test invalid trigger configs.""" with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + await TRIGGERS["event"].async_validate_config( hass, { "platform": f"{DOMAIN}.event", @@ -988,7 +988,7 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: ) with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + await TRIGGERS["value_updated"].async_validate_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1026,7 +1026,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( await hass.config_entries.async_unload(integration.entry_id) # Test full validation for both events - assert await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + assert await TRIGGERS["value_updated"].async_validate_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1036,7 +1036,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( }, ) - assert await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + assert await TRIGGERS["event"].async_validate_config( hass, { "platform": f"{DOMAIN}.event", diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index fc225d529a6..b78d202935d 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -1,12 +1,17 @@ """Test the Z-Wave JS update entities.""" import asyncio +from copy import deepcopy from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest from zwave_js_server.event import Event from zwave_js_server.exceptions import FailedZWaveCommand +from zwave_js_server.model.driver.firmware import DriverFirmwareUpdateStatus +from zwave_js_server.model.node import Node from zwave_js_server.model.node.firmware import NodeFirmwareUpdateStatus from homeassistant.components.update import ( @@ -22,11 +27,16 @@ from homeassistant.components.update import ( SERVICE_SKIP, ) from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE -from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from tests.common import ( @@ -37,7 +47,8 @@ from tests.common import ( ) from tests.typing import WebSocketGenerator -UPDATE_ENTITY = "update.z_wave_thermostat_firmware" +NODE_UPDATE_ENTITY = "update.z_wave_thermostat_firmware" +CONTROLLER_UPDATE_ENTITY = "update.z_stick_gen5_usb_controller_firmware" LATEST_VERSION_FIRMWARE = { "version": "11.2.4", "changelog": "blah 2", @@ -112,26 +123,54 @@ FIRMWARE_UPDATES = { } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.UPDATE] + + +@pytest.fixture(name="controller_state", autouse=True) +def controller_state_fixture( + controller_state: dict[str, Any], +) -> dict[str, Any]: + """Load the controller state fixture data.""" + controller_state = deepcopy(controller_state) + # Set the minimum SDK version that supports firmware updates for controllers. + controller_state["controller"]["sdkVersion"] = "6.50.0" + return controller_state + + +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_states( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, caplog: pytest.LogCaptureFixture, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test update entity states.""" ws_client = await hass_ws_client(hass) - assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + assert client.driver.controller.sdk_version == "6.50.0" + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -139,7 +178,7 @@ async def test_update_entity_states( { "id": 1, "type": "update/release_notes", - "entity_id": UPDATE_ENTITY, + "entity_id": entity_id, } ) result = await ws_client.receive_json() @@ -147,15 +186,15 @@ async def test_update_entity_states( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=2)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes assert not attrs[ATTR_AUTO_UPDATE] - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_LATEST_VERSION] == "11.2.4" assert attrs[ATTR_RELEASE_URL] is None @@ -165,7 +204,7 @@ async def test_update_entity_states( { "id": 2, "type": "update/release_notes", - "entity_id": UPDATE_ENTITY, + "entity_id": entity_id, } ) result = await ws_client.receive_json() @@ -176,7 +215,7 @@ async def test_update_entity_states( DOMAIN, SERVICE_REFRESH_VALUE, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) @@ -185,39 +224,29 @@ async def test_update_entity_states( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=3)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - # Assert a node firmware update entity is not created for the controller - driver = client.driver - node = driver.controller.nodes[1] - assert node.is_controller_node - assert ( - entity_registry.async_get_entity_id( - DOMAIN, - "sensor", - f"{get_valueless_base_unique_id(driver, node)}.firmware_update", - ) - is None - ) - - client.async_send_command.reset_mock() - +@pytest.mark.parametrize( + "entity_id", + [CONTROLLER_UPDATE_ENTITY, NODE_UPDATE_ENTITY], +) async def test_update_entity_install_raises( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, + entity_id: str, ) -> None: """Test update entity install raises exception.""" client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() # Test failed installation by driver @@ -228,7 +257,7 @@ async def test_update_entity_install_raises( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) @@ -236,11 +265,11 @@ async def test_update_entity_install_raises( async def test_update_entity_sleep( hass: HomeAssistant, - client, - zen_31, - integration, + client: MagicMock, + zen_31: Node, + integration: MockConfigEntry, ) -> None: - """Test update occurs when device is asleep after it wakes up.""" + """Test update occurs when device is asleep.""" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": zen_31.node_id}, @@ -250,34 +279,26 @@ async def test_update_entity_sleep( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - # Because node is asleep we shouldn't attempt to check for firmware updates - assert len(client.async_send_command.call_args_list) == 0 - - event = Event( - "wake up", - data={"source": "node", "event": "wake up", "nodeId": zen_31.node_id}, - ) - zen_31.receive_event(event) - await hass.async_block_till_done() - - # Now that the node is up we can check for updates - assert len(client.async_send_command.call_args_list) > 0 - - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + # Two nodes in total, the controller node and the zen_31 node. + # We should check for updates for both nodes, including the sleeping one + # since the firmware check no longer requires device communication first. + assert client.async_send_command.call_count == 2 + # Check calls were made for both nodes + call_args = [call[0][0] for call in client.async_send_command.call_args_list] + assert any(args["nodeId"] == 1 for args in call_args) # Controller node + assert any(args["nodeId"] == 94 for args in call_args) # zen_31 node async def test_update_entity_dead( hass: HomeAssistant, - client, - zen_31, - integration, + client: MagicMock, + zen_31: Node, + integration: MockConfigEntry, ) -> None: - """Test update occurs when device is dead after it becomes alive.""" + """Test update occurs even when device is dead.""" event = Event( "dead", data={"source": "node", "event": "dead", "nodeId": zen_31.node_id}, @@ -287,31 +308,27 @@ async def test_update_entity_dead( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - # Because node is asleep we shouldn't attempt to check for firmware updates - assert len(client.async_send_command.call_args_list) == 0 - - event = Event( - "alive", - data={"source": "node", "event": "alive", "nodeId": zen_31.node_id}, + # Two nodes in total, the controller node and the zen_31 node. + # Checking for firmware updates should proceed even for dead nodes. + assert client.async_send_command.call_count == 2 + calls = sorted( + client.async_send_command.call_args_list, key=lambda call: call[0][0]["nodeId"] ) - zen_31.receive_event(event) - await hass.async_block_till_done() - # Now that the node is up we can check for updates - assert len(client.async_send_command.call_args_list) > 0 - - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + node_ids = (1, 94) + for node_id, call in zip(node_ids, calls, strict=True): + args = call[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == node_id async def test_update_entity_ha_not_running( hass: HomeAssistant, - client, - zen_31, + client: MagicMock, + zen_31: Node, hass_ws_client: WebSocketGenerator, ) -> None: """Test update occurs only after HA is running.""" @@ -324,81 +341,170 @@ async def test_update_entity_ha_not_running( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 + client.async_send_command.reset_mock() + assert client.async_send_command.call_count == 0 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 + assert client.async_send_command.call_count == 0 - # Update should be delayed by a day because HA is not running + # Update should be delayed by a day because Home Assistant is not running hass.set_state(CoreState.starting) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 + assert client.async_send_command.call_count == 0 hass.set_state(CoreState.running) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 5 - args = client.async_send_command.call_args_list[4][0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + # Two nodes in total, the controller node and the zen_31 node. + assert client.async_send_command.call_count == 2 + calls = sorted( + client.async_send_command.call_args_list, key=lambda call: call[0][0]["nodeId"] + ) + + node_ids = (1, 94) + for node_id, call in zip(node_ids, calls, strict=True): + args = call[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == node_id async def test_update_entity_update_failure( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, ) -> None: """Test update entity update failed.""" - assert len(client.async_send_command.call_args_list) == 0 + assert client.async_send_command.call_count == 0 client.async_send_command.side_effect = FailedZWaveCommand("test", 260, "test") - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) - assert state - assert state.state == STATE_OFF - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert ( - args["nodeId"] - == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + entity_ids = (CONTROLLER_UPDATE_ENTITY, NODE_UPDATE_ENTITY) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + assert client.async_send_command.call_count == 2 + calls = sorted( + client.async_send_command.call_args_list, key=lambda call: call[0][0]["nodeId"] ) + node_ids = (1, 26) + for node_id, call in zip(node_ids, calls, strict=True): + args = call[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == node_id + +@pytest.mark.parametrize( + ( + "entity_id", + "installed_version", + "install_result", + "progress_event", + "finished_event", + ), + [ + ( + CONTROLLER_UPDATE_ENTITY, + "1.2", + {"status": 255, "success": True}, + Event( + type="firmware update progress", + data={ + "source": "driver", + "event": "firmware update progress", + "progress": { + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "driver", + "event": "firmware update finished", + "result": { + "status": DriverFirmwareUpdateStatus.OK, + "success": True, + }, + }, + ), + ), + ( + NODE_UPDATE_ENTITY, + "10.7", + {"status": 254, "success": True, "reInterview": False}, + Event( + type="firmware update progress", + data={ + "source": "node", + "event": "firmware update progress", + "nodeId": 26, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "node", + "event": "firmware update finished", + "nodeId": 26, + "result": { + "status": NodeFirmwareUpdateStatus.OK_NO_RESTART, + "success": True, + "reInterview": False, + }, + }, + ), + ), + ], +) async def test_update_entity_progress( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, + entity_id: str, + installed_version: str, + install_result: dict[str, Any], + progress_event: Event, + finished_event: Event, ) -> None: """Test update entity progress.""" - node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES + driver = client.driver - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = { - "result": {"status": 2, "success": False, "reInterview": False} - } + client.async_send_command.return_value = {"result": install_result} # Test successful install call without a version install_task = hass.async_create_task( @@ -406,64 +512,36 @@ async def test_update_entity_progress( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) ) # Sleep so that task starts - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] is None - event = Event( - type="firmware update progress", - data={ - "source": "node", - "event": "firmware update progress", - "nodeId": node.node_id, - "progress": { - "currentFile": 1, - "totalFiles": 1, - "sentFragments": 1, - "totalFragments": 20, - "progress": 5.0, - }, - }, - ) - node.receive_event(event) + driver.receive_event(progress_event) + await asyncio.sleep(0.05) # Validate that the progress is updated - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] == 5 - event = Event( - type="firmware update finished", - data={ - "source": "node", - "event": "firmware update finished", - "nodeId": node.node_id, - "result": { - "status": NodeFirmwareUpdateStatus.OK_NO_RESTART, - "success": True, - "reInterview": False, - }, - }, - ) - - node.receive_event(event) + driver.receive_event(finished_event) await hass.async_block_till_done() # Validate that progress is reset and entity reflects new version - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is False @@ -475,31 +553,106 @@ async def test_update_entity_progress( await install_task +@pytest.mark.parametrize( + ( + "entity_id", + "installed_version", + "install_result", + "progress_event", + "finished_event", + ), + [ + ( + CONTROLLER_UPDATE_ENTITY, + "1.2", + {"status": 0, "success": False}, + Event( + type="firmware update progress", + data={ + "source": "driver", + "event": "firmware update progress", + "progress": { + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "driver", + "event": "firmware update finished", + "result": { + "status": DriverFirmwareUpdateStatus.ERROR_TIMEOUT, + "success": False, + }, + }, + ), + ), + ( + NODE_UPDATE_ENTITY, + "10.7", + {"status": -1, "success": False, "reInterview": False}, + Event( + type="firmware update progress", + data={ + "source": "node", + "event": "firmware update progress", + "nodeId": 26, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "node", + "event": "firmware update finished", + "nodeId": 26, + "result": { + "status": NodeFirmwareUpdateStatus.ERROR_TIMEOUT, + "success": False, + "reInterview": False, + }, + }, + ), + ), + ], +) async def test_update_entity_install_failed( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, caplog: pytest.LogCaptureFixture, + entity_id: str, + installed_version: str, + install_result: dict[str, Any], + progress_event: Event, + finished_event: Event, ) -> None: """Test update entity install returns error status.""" - node = climate_radio_thermostat_ct100_plus_different_endpoints + driver = client.driver client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = { - "result": {"status": 2, "success": False, "reInterview": False} - } + client.async_send_command.return_value = {"result": install_result} # Test install call - we expect it to finish fail install_task = hass.async_create_task( @@ -507,63 +660,35 @@ async def test_update_entity_install_failed( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) ) # Sleep so that task starts - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) - event = Event( - type="firmware update progress", - data={ - "source": "node", - "event": "firmware update progress", - "nodeId": node.node_id, - "progress": { - "currentFile": 1, - "totalFiles": 1, - "sentFragments": 1, - "totalFragments": 20, - "progress": 5.0, - }, - }, - ) - node.receive_event(event) + driver.receive_event(progress_event) + await asyncio.sleep(0.05) # Validate that the progress is updated - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] == 5 - event = Event( - type="firmware update finished", - data={ - "source": "node", - "event": "firmware update finished", - "nodeId": node.node_id, - "result": { - "status": NodeFirmwareUpdateStatus.ERROR_TIMEOUT, - "success": False, - "reInterview": False, - }, - }, - ) - - node.receive_event(event) + driver.receive_event(finished_event) await hass.async_block_till_done() # Validate that progress is reset and entity reflects old version - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_UPDATE_PERCENTAGE] is None - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_LATEST_VERSION] == "11.2.4" assert state.state == STATE_ON @@ -572,35 +697,44 @@ async def test_update_entity_install_failed( await install_task +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_reload( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, + entity_id: str, + installed_version: str, ) -> None: """Test update entity maintains state after reload.""" - assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + config_entry = integration + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=2)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes assert not attrs[ATTR_AUTO_UPDATE] - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_UPDATE_PERCENTAGE] is None assert attrs[ATTR_LATEST_VERSION] == "11.2.4" @@ -610,24 +744,24 @@ async def test_update_entity_reload( UPDATE_DOMAIN, SERVICE_SKIP, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" - await hass.config_entries.async_reload(integration.entry_id) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() # Trigger another update and make sure the skipped version is still skipped - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=4)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=4)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" @@ -635,9 +769,9 @@ async def test_update_entity_reload( async def test_update_entity_delay( hass: HomeAssistant, - client, - ge_in_wall_dimmer_switch, - zen_31, + client: MagicMock, + ge_in_wall_dimmer_switch: Node, + zen_31: Node, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, ) -> None: @@ -651,22 +785,23 @@ async def test_update_entity_delay( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 6 + client.async_send_command.reset_mock() + assert client.async_send_command.call_count == 0 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 6 + assert client.async_send_command.call_count == 0 - update_interval = timedelta(minutes=5) + update_interval = timedelta(seconds=15) freezer.tick(update_interval) async_fire_time_changed(hass) await hass.async_block_till_done() nodes: set[int] = set() - assert len(client.async_send_command.call_args_list) == 7 - args = client.async_send_command.call_args_list[6][0][0] + assert client.async_send_command.call_count == 1 + args = client.async_send_command.call_args[0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) @@ -674,30 +809,45 @@ async def test_update_entity_delay( async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 8 - args = client.async_send_command.call_args_list[7][0][0] + assert client.async_send_command.call_count == 2 + args = client.async_send_command.call_args[0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) - assert len(nodes) == 2 - assert nodes == {ge_in_wall_dimmer_switch.node_id, zen_31.node_id} + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert client.async_send_command.call_count == 3 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + nodes.add(args["nodeId"]) + + assert len(nodes) == 3 + assert nodes == {1, ge_in_wall_dimmer_switch.node_id, zen_31.node_id} +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_partial_restore_data( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test update entity with partial restore data resets state.""" mock_restore_cache( hass, [ State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "11.2.4", ATTR_SKIPPED_VERSION: "11.2.4", }, @@ -709,16 +859,22 @@ async def test_update_entity_partial_restore_data( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_partial_restore_data_2( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test second scenario where update entity has partial restore data.""" mock_restore_cache_with_extra_data( @@ -726,10 +882,10 @@ async def test_update_entity_partial_restore_data_2( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_ON, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "10.8", ATTR_SKIPPED_VERSION: None, }, @@ -743,18 +899,24 @@ async def test_update_entity_partial_restore_data_2( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] is None +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_full_restore_data_skipped_version( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test update entity with full restore data (skipped version) restores state.""" mock_restore_cache_with_extra_data( @@ -762,10 +924,10 @@ async def test_update_entity_full_restore_data_skipped_version( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "11.2.4", ATTR_SKIPPED_VERSION: "11.2.4", }, @@ -779,18 +941,44 @@ async def test_update_entity_full_restore_data_skipped_version( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" +@pytest.mark.parametrize( + ("entity_id", "installed_version", "install_result", "install_command_params"), + [ + ( + CONTROLLER_UPDATE_ENTITY, + "1.2", + {"status": 255, "success": True}, + { + "command": "driver.firmware_update_otw", + }, + ), + ( + NODE_UPDATE_ENTITY, + "10.7", + {"status": 255, "success": True, "reInterview": False}, + { + "command": "controller.firmware_update_ota", + "nodeId": 26, + }, + ), + ], +) async def test_update_entity_full_restore_data_update_available( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, + install_result: dict[str, Any], + install_command_params: dict[str, Any], ) -> None: """Test update entity with full restore data (update available) restores state.""" mock_restore_cache_with_extra_data( @@ -798,10 +986,10 @@ async def test_update_entity_full_restore_data_update_available( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "11.2.4", ATTR_SKIPPED_VERSION: None, }, @@ -815,15 +1003,14 @@ async def test_update_entity_full_restore_data_update_available( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" - client.async_send_command.return_value = { - "result": {"status": 255, "success": True, "reInterview": False} - } + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"result": install_result} # Test successful install call without a version install_task = hass.async_create_task( @@ -831,25 +1018,24 @@ async def test_update_entity_full_restore_data_update_available( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) ) # Sleep so that task starts - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] is None - assert len(client.async_send_command.call_args_list) == 5 - assert client.async_send_command.call_args_list[4][0][0] == { - "command": "controller.firmware_update_ota", - "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + assert client.async_send_command.call_count == 1 + assert client.async_send_command.call_args[0][0] == { + **install_command_params, "updateInfo": { "version": "11.2.4", "changelog": "blah 2", @@ -872,11 +1058,18 @@ async def test_update_entity_full_restore_data_update_available( install_task.cancel() +@pytest.mark.parametrize( + ("entity_id", "installed_version", "latest_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2", "1.2"), (NODE_UPDATE_ENTITY, "10.7", "10.7")], +) async def test_update_entity_full_restore_data_no_update_available( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, + latest_version: str, ) -> None: """Test entity with full restore data (no update available) restores state.""" mock_restore_cache_with_extra_data( @@ -884,11 +1077,11 @@ async def test_update_entity_full_restore_data_no_update_available( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", - ATTR_LATEST_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, + ATTR_LATEST_VERSION: latest_version, ATTR_SKIPPED_VERSION: None, }, ), @@ -901,18 +1094,25 @@ async def test_update_entity_full_restore_data_no_update_available( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None - assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + assert state.attributes[ATTR_LATEST_VERSION] == latest_version +@pytest.mark.parametrize( + ("entity_id", "installed_version", "latest_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2", "1.2"), (NODE_UPDATE_ENTITY, "10.7", "10.7")], +) async def test_update_entity_no_latest_version( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, + latest_version: str, ) -> None: """Test entity with no `latest_version` attr restores state.""" mock_restore_cache_with_extra_data( @@ -920,10 +1120,10 @@ async def test_update_entity_no_latest_version( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: None, ATTR_SKIPPED_VERSION: None, }, @@ -937,24 +1137,8 @@ async def test_update_entity_no_latest_version( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None - assert state.attributes[ATTR_LATEST_VERSION] == "10.7" - - -async def test_update_entity_unload_asleep_node( - hass: HomeAssistant, client, wallmote_central_scene, integration -) -> None: - """Test unloading config entry after attempting an update for an asleep node.""" - assert len(client.async_send_command.call_args_list) == 0 - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) - await hass.async_block_till_done() - - assert len(client.async_send_command.call_args_list) == 0 - assert len(wallmote_central_scene._listeners["wake up"]) == 2 - - await hass.config_entries.async_unload(integration.entry_id) - assert len(wallmote_central_scene._listeners["wake up"]) == 0 + assert state.attributes[ATTR_LATEST_VERSION] == latest_version diff --git a/tests/conftest.py b/tests/conftest.py index ef31eee4004..acb50b0029c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1724,7 +1724,7 @@ async def async_test_recorder( wait_recorder: bool = True, wait_recorder_setup: bool = True, ) -> AsyncGenerator[recorder.Recorder]: - """Setup and return recorder instance.""" # noqa: D401 + """Setup and return recorder instance.""" await _async_init_recorder_component( hass, config, @@ -1817,6 +1817,7 @@ async def mock_enable_bluetooth( def mock_bluetooth_adapters() -> Generator[None]: """Fixture to mock bluetooth adapters.""" with ( + patch("habluetooth.util.recover_adapter"), patch("bluetooth_auto_recovery.recover_adapter"), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index b9259596c65..329357bfca4 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -1,11 +1,22 @@ """Tests for hassfest requirements.""" +from collections.abc import Generator +from importlib.metadata import PackagePath from pathlib import Path +from unittest.mock import patch import pytest from script.hassfest.model import Config, Integration -from script.hassfest.requirements import validate_requirements_format +from script.hassfest.requirements import ( + FORBIDDEN_PACKAGE_NAMES, + PACKAGE_CHECK_PREPARE_UPDATE, + PACKAGE_CHECK_VERSION_RANGE, + _packages_checked_files_cache, + check_dependency_files, + check_dependency_version_range, + validate_requirements_format, +) @pytest.fixture @@ -29,6 +40,19 @@ def integration(): ) +@pytest.fixture +def mock_forbidden_package_names() -> Generator[None]: + """Fixture for FORBIDDEN_PACKAGE_NAMES.""" + # pylint: disable-next=global-statement + global FORBIDDEN_PACKAGE_NAMES # noqa: PLW0603 + original = FORBIDDEN_PACKAGE_NAMES.copy() + FORBIDDEN_PACKAGE_NAMES = {"test", "tests"} + try: + yield + finally: + FORBIDDEN_PACKAGE_NAMES = original + + def test_validate_requirements_format_with_space(integration: Integration) -> None: """Test validate requirement with space around separator.""" integration.manifest["requirements"] = ["test_package == 1"] @@ -105,3 +129,193 @@ def test_validate_requirements_format_github_custom(integration: Integration) -> integration.path = Path("") assert validate_requirements_format(integration) assert len(integration.errors) == 0 + + +@pytest.mark.parametrize( + ("version", "result"), + [ + (">2", True), + (">=2.0", True), + (">=2.0,<4", True), + ("<4", True), + ("<=3.0", True), + (">=2.0,<4;python_version<'3.14'", True), + ("<3", False), + ("==2.*", False), + ("~=2.0", False), + ("<=2.100", False), + (">2,<3", False), + (">=2.0,<3", False), + (">=2.0,<3;python_version<'3.14'", False), + ], +) +def test_dependency_version_range_prepare_update( + version: str, result: bool, integration: Integration +) -> None: + """Test dependency version range check for prepare update is working correctly.""" + with ( + patch.dict(PACKAGE_CHECK_VERSION_RANGE, {"numpy-test": "SemVer"}, clear=True), + patch.dict(PACKAGE_CHECK_PREPARE_UPDATE, {"numpy-test": 3}, clear=True), + ): + assert ( + check_dependency_version_range( + integration, + "test", + pkg="numpy-test", + version=version, + package_exceptions=set(), + ) + == result + ) + + +@pytest.mark.usefixtures("mock_forbidden_package_names") +def test_check_dependency_package_names(integration: Integration) -> None: + """Test dependency package names check for forbidden package names is working correctly.""" + package = "homeassistant" + pkg = "my_package" + + # Forbidden top level directories: test, tests + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package-1.0.0.dist-info/METADATA"), + PackagePath("tests/test_some_function.py"), + PackagePath("test/submodule/test_some_other_function.py"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert _packages_checked_files_cache[pkg]["top_level"] == {"tests", "test"} + assert len(integration.errors) == 2 + assert ( + f"Package {pkg} has a forbidden top level directory 'tests' in {package}" + in [x.error for x in integration.errors] + ) + assert ( + f"Package {pkg} has a forbidden top level directory 'test' in {package}" + in [x.error for x in integration.errors] + ) + integration.errors.clear() + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert mock_files.call_count == 1 + assert len(integration.errors) == 2 + integration.errors.clear() + + # Exceptions set + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package.dist-info/METADATA"), + PackagePath("tests/test_some_function.py"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert ( + check_dependency_files(integration, package, pkg, package_exceptions={pkg}) + is False + ) + assert _packages_checked_files_cache[pkg]["top_level"] == {"tests"} + assert len(integration.errors) == 0 + assert len(integration.warnings) == 1 + assert ( + f"Package {pkg} has a forbidden top level directory 'tests' in {package}" + in [x.error for x in integration.warnings] + ) + integration.warnings.clear() + + # Repeated call should use cache + assert ( + check_dependency_files(integration, package, pkg, package_exceptions={pkg}) + is False + ) + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 + assert len(integration.warnings) == 1 + integration.warnings.clear() + + # All good + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert _packages_checked_files_cache[pkg]["top_level"] == set() + assert len(integration.errors) == 0 + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 + + +def test_check_dependency_file_names(integration: Integration) -> None: + """Test dependency file name check for forbidden files is working correctly.""" + package = "homeassistant" + pkg = "my_package" + + # Forbidden file: 'py.typed' at top level + pkg_files = [ + PackagePath("py.typed"), + PackagePath("my_package.py"), + PackagePath("my_package-1.0.0.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert _packages_checked_files_cache[pkg]["file_names"] == {"py.typed"} + assert len(integration.errors) == 1 + assert f"Package {pkg} has a forbidden file 'py.typed' in {package}" in [ + x.error for x in integration.errors + ] + integration.errors.clear() + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert mock_files.call_count == 1 + assert len(integration.errors) == 1 + integration.errors.clear() + + # All good + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package/py.typed"), + PackagePath("my_package.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert _packages_checked_files_cache[pkg]["file_names"] == set() + assert len(integration.errors) == 0 + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index 55ff772e08e..2da81a95602 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -21,7 +21,6 @@ '1234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'test-manuf', @@ -31,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Heliport', 'sw_version': 'test-sw', 'via_device_id': , }) @@ -58,7 +56,6 @@ 'efgh', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'test-manuf', @@ -68,7 +65,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Heliport', 'sw_version': 'test-sw', 'via_device_id': , }) diff --git a/tests/helpers/test_automation.py b/tests/helpers/test_automation.py new file mode 100644 index 00000000000..1cd9944aecf --- /dev/null +++ b/tests/helpers/test_automation.py @@ -0,0 +1,36 @@ +"""Test automation helpers.""" + +import pytest + +from homeassistant.helpers.automation import ( + get_absolute_description_key, + get_relative_description_key, +) + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_absolute_description_key(relative_key: str, absolute_key: str) -> None: + """Test absolute description key.""" + DOMAIN = "homeassistant" + assert get_absolute_description_key(DOMAIN, relative_key) == absolute_key + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_relative_description_key(relative_key: str, absolute_key: str) -> None: + """Test relative description key.""" + DOMAIN = "homeassistant" + assert get_relative_description_key(DOMAIN, absolute_key) == relative_key diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py deleted file mode 100644 index f6a4f28622e..00000000000 --- a/tests/helpers/test_backup.py +++ /dev/null @@ -1,41 +0,0 @@ -"""The tests for the backup helpers.""" - -import asyncio -from unittest.mock import patch - -import pytest - -from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import backup as backup_helper -from homeassistant.setup import async_setup_component - - -async def test_async_get_manager(hass: HomeAssistant) -> None: - """Test async_get_manager.""" - backup_helper.async_initialize_backup(hass) - task = asyncio.create_task(backup_helper.async_get_manager(hass)) - assert await async_setup_component(hass, BACKUP_DOMAIN, {}) - await hass.async_block_till_done() - manager = await task - assert manager is hass.data[backup_helper.DATA_MANAGER] - - -async def test_async_get_manager_no_backup(hass: HomeAssistant) -> None: - """Test async_get_manager when the backup integration is not enabled.""" - with pytest.raises(HomeAssistantError, match="Backup integration is not available"): - await backup_helper.async_get_manager(hass) - - -async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> None: - """Test test_async_get_manager when the backup integration can't be set up.""" - backup_helper.async_initialize_backup(hass) - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_setup", - side_effect=Exception("Boom!"), - ): - assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) - with pytest.raises(Exception, match="Boom!"): - await backup_helper.async_get_manager(hass) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 7285301f12b..b037d6a450e 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,14 +1,21 @@ """Test the condition helper.""" from datetime import timedelta +import io from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time import pytest +from pytest_unordered import unordered import voluptuous as vol +from homeassistant.components.device_automation import ( + DOMAIN as DOMAIN_DEVICE_AUTOMATION, +) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sun import DOMAIN as DOMAIN_SUN +from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH from homeassistant.const import ( ATTR_DEVICE_CLASS, CONF_CONDITION, @@ -26,8 +33,13 @@ from homeassistant.helpers import ( trace, ) from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.yaml.loader import parse_yaml + +from tests.common import MockModule, MockPlatform, mock_integration, mock_platform def assert_element(trace_element, expected_element, path): @@ -1880,201 +1892,6 @@ async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None: ) -async def test_zone_raises(hass: HomeAssistant) -> None: - """Test that zone raises ConditionError on errors.""" - config = { - "condition": "zone", - "entity_id": "device_tracker.cat", - "zone": "zone.home", - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - with pytest.raises(ConditionError, match="no zone"): - condition.zone(hass, zone_ent=None, entity="sensor.any") - - with pytest.raises(ConditionError, match="unknown zone"): - test(hass) - - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, - ) - - with pytest.raises(ConditionError, match="no entity"): - condition.zone(hass, zone_ent="zone.home", entity=None) - - with pytest.raises(ConditionError, match="unknown entity"): - test(hass) - - hass.states.async_set( - "device_tracker.cat", - "home", - {"friendly_name": "cat"}, - ) - - with pytest.raises(ConditionError, match="latitude"): - test(hass) - - hass.states.async_set( - "device_tracker.cat", - "home", - {"friendly_name": "cat", "latitude": 2.1}, - ) - - with pytest.raises(ConditionError, match="longitude"): - test(hass) - - hass.states.async_set( - "device_tracker.cat", - "home", - {"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1}, - ) - - # All okay, now test multiple failed conditions - assert test(hass) - - config = { - "condition": "zone", - "entity_id": ["device_tracker.cat", "device_tracker.dog"], - "zone": ["zone.home", "zone.work"], - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - with pytest.raises(ConditionError, match="dog"): - test(hass) - - with pytest.raises(ConditionError, match="work"): - test(hass) - - hass.states.async_set( - "zone.work", - "zoning", - {"name": "work", "latitude": 20, "longitude": 10, "radius": 25000}, - ) - - hass.states.async_set( - "device_tracker.dog", - "work", - {"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1}, - ) - - assert test(hass) - - -async def test_zone_multiple_entities(hass: HomeAssistant) -> None: - """Test with multiple entities in condition.""" - config = { - "condition": "and", - "conditions": [ - { - "alias": "Zone Condition", - "condition": "zone", - "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], - "zone": "zone.home", - }, - ], - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, - ) - - hass.states.async_set( - "device_tracker.person_1", - "home", - {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, - ) - hass.states.async_set( - "device_tracker.person_2", - "home", - {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, - ) - assert test(hass) - - hass.states.async_set( - "device_tracker.person_1", - "home", - {"friendly_name": "person_1", "latitude": 20.1, "longitude": 10.1}, - ) - hass.states.async_set( - "device_tracker.person_2", - "home", - {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, - ) - assert not test(hass) - - hass.states.async_set( - "device_tracker.person_1", - "home", - {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, - ) - hass.states.async_set( - "device_tracker.person_2", - "home", - {"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1}, - ) - assert not test(hass) - - -async def test_multiple_zones(hass: HomeAssistant) -> None: - """Test with multiple entities in condition.""" - config = { - "condition": "and", - "conditions": [ - { - "condition": "zone", - "entity_id": "device_tracker.person", - "zone": ["zone.home", "zone.work"], - }, - ], - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, - ) - hass.states.async_set( - "zone.work", - "zoning", - {"name": "work", "latitude": 20.1, "longitude": 10.1, "radius": 10}, - ) - - hass.states.async_set( - "device_tracker.person", - "home", - {"friendly_name": "person", "latitude": 2.1, "longitude": 1.1}, - ) - assert test(hass) - - hass.states.async_set( - "device_tracker.person", - "home", - {"friendly_name": "person", "latitude": 20.1, "longitude": 10.1}, - ) - assert test(hass) - - hass.states.async_set( - "device_tracker.person", - "home", - {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, - ) - assert not test(hass) - - @pytest.mark.usefixtures("hass") async def test_extract_entities() -> None: """Test extracting entities.""" @@ -2251,15 +2068,78 @@ async def test_trigger(hass: HomeAssistant) -> None: assert test(hass, {"trigger": {"id": "123456"}}) -async def test_platform_async_validate_condition_config(hass: HomeAssistant) -> None: - """Test platform.async_validate_condition_config will be called if it exists.""" +async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: + """Test platform.async_get_conditions will be called if it exists.""" config = {CONF_DEVICE_ID: "test", CONF_DOMAIN: "test", CONF_CONDITION: "device"} with patch( - "homeassistant.components.device_automation.condition.async_validate_condition_config", - AsyncMock(), - ) as device_automation_validate_condition_mock: + "homeassistant.components.device_automation.condition.async_get_conditions", + AsyncMock(return_value={"_device": AsyncMock()}), + ) as device_automation_async_get_conditions_mock: await condition.async_validate_condition_config(hass, config) - device_automation_validate_condition_mock.assert_awaited() + device_automation_async_get_conditions_mock.assert_awaited() + + +async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: + """Test a condition platform with multiple conditions.""" + + class MockCondition(condition.Condition): + """Mock condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + class MockCondition1(MockCondition): + """Mock condition 1.""" + + async def async_get_checker(self) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + return lambda hass, vars: True + + class MockCondition2(MockCondition): + """Mock condition 2.""" + + async def async_get_checker(self) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + return lambda hass, vars: False + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[condition.Condition]]: + return { + "_": MockCondition1, + "cond_2": MockCondition2, + } + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + config_1 = {CONF_CONDITION: "test"} + config_2 = {CONF_CONDITION: "test.cond_2"} + config_3 = {CONF_CONDITION: "test.unknown_cond"} + assert await condition.async_validate_condition_config(hass, config_1) == config_1 + assert await condition.async_validate_condition_config(hass, config_2) == config_2 + with pytest.raises( + vol.Invalid, match="Invalid condition 'test.unknown_cond' specified" + ): + await condition.async_validate_condition_config(hass, config_3) + + cond_func = await condition.async_from_config(hass, config_1) + assert cond_func(hass, {}) is True + + cond_func = await condition.async_from_config(hass, config_2) + assert cond_func(hass, {}) is False + + with pytest.raises(KeyError): + await condition.async_from_config(hass, config_3) @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) @@ -2451,3 +2331,280 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None "conditions/1/entity_id/0": [{"result": {"result": True, "state": 100.0}}], } ) + + +@pytest.mark.parametrize( + "sun_condition_descriptions", + [ + """ + _: + fields: + after: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + after_offset: + selector: + time: null + before: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + before_offset: + selector: + time: null + """, + """ + .sunrise_sunset_selector: &sunrise_sunset_selector + example: sunrise + selector: + select: + options: + - sunrise + - sunset + .offset_selector: &offset_selector + selector: + time: null + _: + fields: + after: *sunrise_sunset_selector + after_offset: *offset_selector + before: *sunrise_sunset_selector + before_offset: *offset_selector + """, + ], +) +async def test_async_get_all_descriptions( + hass: HomeAssistant, sun_condition_descriptions: str +) -> None: + """Test async_get_all_descriptions.""" + device_automation_condition_descriptions = """ + _device: {} + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + if fname.endswith("device_automation/conditions.yaml"): + condition_descriptions = device_automation_condition_descriptions + elif fname.endswith("sun/conditions.yaml"): + condition_descriptions = sun_condition_descriptions + with io.StringIO(condition_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "homeassistant.helpers.condition._load_conditions_files", + side_effect=condition._load_conditions_files, + ) as proxy_load_conditions_files, + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + descriptions = await condition.async_get_all_descriptions(hass) + + # Test we only load conditions.yaml for integrations with conditions, + # system_health has no conditions + assert proxy_load_conditions_files.mock_calls[0][1][0] == unordered( + [ + await async_get_integration(hass, DOMAIN_SUN), + ] + ) + + # system_health does not have conditions and should not be in descriptions + assert descriptions == { + "sun": { + "fields": { + "after": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "after_offset": {"selector": {"time": None}}, + "before": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "before_offset": {"selector": {"time": None}}, + } + } + } + + # Verify the cache returns the same object + assert await condition.async_get_all_descriptions(hass) is descriptions + + # Load the device_automation integration and check a new cache object is created + assert await async_setup_component(hass, DOMAIN_DEVICE_AUTOMATION, {}) + await hass.async_block_till_done() + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + new_descriptions = await condition.async_get_all_descriptions(hass) + assert new_descriptions is not descriptions + assert new_descriptions == { + "device": { + "fields": {}, + }, + "sun": { + "fields": { + "after": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "after_offset": {"selector": {"time": None}}, + "before": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "before_offset": {"selector": {"time": None}}, + } + }, + } + + # Verify the cache returns the same object + assert await condition.async_get_all_descriptions(hass) is new_descriptions + + +@pytest.mark.parametrize( + ("yaml_error", "expected_message"), + [ + ( + FileNotFoundError("Blah"), + "Unable to find conditions.yaml for the sun integration", + ), + ( + HomeAssistantError("Test error"), + "Unable to parse conditions.yaml for the sun integration: Test error", + ), + ], +) +async def test_async_get_all_descriptions_with_yaml_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + yaml_error: Exception, + expected_message: str, +) -> None: + """Test async_get_all_descriptions.""" + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml_dict(fname, secrets=None): + raise yaml_error + + with ( + patch( + "homeassistant.helpers.condition.load_yaml_dict", + side_effect=_load_yaml_dict, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + descriptions = await condition.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert expected_message in caplog.text + + +async def test_async_get_all_descriptions_with_bad_description( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_get_all_descriptions.""" + sun_service_descriptions = """ + _: + fields: not_a_dict + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + with io.StringIO(sun_service_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + descriptions = await condition.async_get_all_descriptions(hass) + + assert descriptions == {"sun": None} + + assert ( + "Unable to parse conditions.yaml for the sun integration: " + "expected a dictionary for dictionary value @ data['_']['fields']" + ) in caplog.text + + +async def test_invalid_condition_platform( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid condition platform.""" + mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True))) + mock_platform(hass, "test.condition", MockPlatform()) + + await async_setup_component(hass, "test", {}) + + assert ( + "Integration test does not provide condition support, skipping" in caplog.text + ) + + +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_conditions", return_value=True) +async def test_subscribe_conditions( + mock_has_conditions: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test condition.async_subscribe_platform_events.""" + sun_condition_descriptions = """ + sun: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/conditions.yaml"): + condition_descriptions = sun_condition_descriptions + else: + raise FileNotFoundError + with io.StringIO(condition_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + async def broken_subscriber(_): + """Simulate a broken subscriber.""" + raise Exception("Boom!") # noqa: TRY002 + + condition_events = [] + + async def good_subscriber(new_conditions: set[str]): + """Simulate a working subscriber.""" + condition_events.append(new_conditions) + + condition.async_subscribe_platform_events(hass, broken_subscriber) + condition.async_subscribe_platform_events(hass, good_subscriber) + + assert await async_setup_component(hass, "sun", {}) + + assert condition_events == [{"sun"}] + assert "Error while notifying condition platform listener" in caplog.text diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index a74055c59ec..d45c9ce1546 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -135,7 +135,7 @@ def test_deprecated_class(mock_get_logger) -> None: ("breaks_in_ha_version", "extra_msg"), [ (None, ""), - ("2099.1", " which will be removed in HA Core 2099.1"), + ("2099.1", " It will be removed in HA Core 2099.1."), ], ) def test_deprecated_function( @@ -154,8 +154,9 @@ def test_deprecated_function( mock_deprecated_function() assert ( - f"mock_deprecated_function is a deprecated function{extra_msg}. " - "Use new_function instead" + "The deprecated function mock_deprecated_function was called." + f"{extra_msg}" + " Use new_function instead" ) in caplog.text @@ -163,7 +164,7 @@ def test_deprecated_function( ("breaks_in_ha_version", "extra_msg"), [ (None, ""), - ("2099.1", " which will be removed in HA Core 2099.1"), + ("2099.1", " It will be removed in HA Core 2099.1."), ], ) def test_deprecated_function_called_from_built_in_integration( @@ -210,9 +211,9 @@ def test_deprecated_function_called_from_built_in_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, " - f"this is a deprecated function{extra_msg}. " - "Use new_function instead" + "The deprecated function mock_deprecated_function was called from hue." + f"{extra_msg}" + " Use new_function instead" ) in caplog.text @@ -220,7 +221,7 @@ def test_deprecated_function_called_from_built_in_integration( ("breaks_in_ha_version", "extra_msg"), [ (None, ""), - ("2099.1", " which will be removed in HA Core 2099.1"), + ("2099.1", " It will be removed in HA Core 2099.1."), ], ) def test_deprecated_function_called_from_custom_integration( @@ -270,9 +271,9 @@ def test_deprecated_function_called_from_custom_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, " - f"this is a deprecated function{extra_msg}. " - "Use new_function instead, please report it to the author of the " + "The deprecated function mock_deprecated_function was called from hue." + f"{extra_msg}" + " Use new_function instead, please report it to the author of the " "'hue' custom integration" ) in caplog.text @@ -316,7 +317,7 @@ def _get_value( ), ( DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), - " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ". It will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", "constant", ), ( @@ -326,7 +327,7 @@ def _get_value( ), ( DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), - " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ". It will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", "constant", ), ( @@ -336,7 +337,7 @@ def _get_value( ), ( DeprecatedAlias(1, "new_alias", "2099.1"), - " which will be removed in HA Core 2099.1. Use new_alias instead", + ". It will be removed in HA Core 2099.1. Use new_alias instead", "alias", ), ], @@ -405,7 +406,7 @@ def test_check_if_deprecated_constant( assert ( module_name, logging.WARNING, - f"TEST_CONSTANT was used from hue, this is a deprecated {description}{extra_msg}{extra_extra_msg}", + f"The deprecated {description} TEST_CONSTANT was used from hue{extra_msg}{extra_extra_msg}", ) in caplog.record_tuples @@ -594,7 +595,7 @@ def test_enum_with_deprecated_members( "tests.helpers.test_deprecation", logging.WARNING, ( - "TestEnum.CATS was used from hue, this is a deprecated enum member which " + "The deprecated enum member TestEnum.CATS was used from hue. It " "will be removed in HA Core 2025.11.0. Use TestEnum.CATS_PER_CM instead" f"{extra_extra_msg}" ), @@ -603,7 +604,7 @@ def test_enum_with_deprecated_members( "tests.helpers.test_deprecation", logging.WARNING, ( - "TestEnum.DOGS was used from hue, this is a deprecated enum member. Use " + "The deprecated enum member TestEnum.DOGS was used from hue. Use " f"TestEnum.DOGS_PER_CM instead{extra_extra_msg}" ), ) in caplog.record_tuples diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 266435ef05d..262e700c29e 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -8,6 +8,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device import ( async_device_info_to_link_from_device_id, async_device_info_to_link_from_entity, + async_entity_id_to_device, async_entity_id_to_device_id, async_remove_stale_devices_links_keep_current_device, async_remove_stale_devices_links_keep_entity_device, @@ -16,12 +17,12 @@ from homeassistant.helpers.device import ( from tests.common import MockConfigEntry -async def test_entity_id_to_device_id( +async def test_entity_id_to_device_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test returning an entity's device ID.""" + """Test returning an entity's device / device ID.""" config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -48,6 +49,41 @@ async def test_entity_id_to_device_id( entity_id_or_uuid=entity.entity_id, ) assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.entity_id, + ) + == device + ) + + assert ( + async_entity_id_to_device_id( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + + device_id = async_entity_id_to_device_id( + hass, + entity_id_or_uuid=entity.id, + ) + assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.id, + ) + == device + ) with pytest.raises(vol.Invalid): async_entity_id_to_device_id( @@ -55,6 +91,12 @@ async def test_entity_id_to_device_id( entity_id_or_uuid="unknown_uuid", ) + with pytest.raises(vol.Invalid): + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown_uuid", + ) + async def test_device_info_to_link( hass: HomeAssistant, diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index c8ec83934ac..d056c25fc3b 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -107,7 +107,6 @@ async def test_get_or_create_returns_same_entry( assert entry3.model == "model" assert entry3.name == "name" assert entry3.sw_version == "sw-version" - assert entry3.suggested_area == "Game Room" assert entry3.area_id == game_room_area.id await hass.async_block_till_done() @@ -409,7 +408,6 @@ async def test_loading_from_storage( name="name", primary_config_entry=mock_config_entry.entry_id, serial_number="serial_no", - suggested_area=None, # Not stored sw_version="version", ) assert isinstance(entry.config_entries, set) @@ -1432,6 +1430,141 @@ async def test_migration_from_1_7( } +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_10( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 1.10.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "123456ABCDEF"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "123456ABCDAB"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + ) + assert entry.id == "abcdefghijklm" + deleted_entry = registry.deleted_devices.get_entry( + connections=set(), + identifiers={("serial", "123456ABCDAB")}, + ) + assert deleted_entry.id == "abcdefghijklm2" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + sw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + + assert hass_storage[dr.STORAGE_KEY] == { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "12:34:56:ab:cd:ab"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + async def test_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -1517,6 +1650,7 @@ async def test_removing_config_entries( assert update_events[4].data == { "action": "remove", "device_id": entry3.id, + "device": entry3.dict_repr, } @@ -1589,10 +1723,12 @@ async def test_deleted_device_removing_config_entries( assert update_events[3].data == { "action": "remove", "device_id": entry.id, + "device": entry2.dict_repr, } assert update_events[4].data == { "action": "remove", "device_id": entry3.id, + "device": entry3.dict_repr, } device_registry.async_clear_config_entry(config_entry_1.entry_id) @@ -1838,6 +1974,7 @@ async def test_removing_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry.id, + "device": entry.dict_repr, } @@ -1967,6 +2104,7 @@ async def test_deleted_device_removing_config_subentries( assert update_events[4].data == { "action": "remove", "device_id": entry.id, + "device": entry4.dict_repr, } device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) @@ -2369,13 +2507,13 @@ async def test_loading_saving_data( # Ensure a save/load cycle does not keep suggested area new_kitchen_light = registry2.async_get_device(identifiers={("hue", "999")}) - assert orig_kitchen_light.suggested_area == "Kitchen" + assert orig_kitchen_light.area_id == "kitchen" - orig_kitchen_light_witout_suggested_area = device_registry.async_update_device( + orig_kitchen_light_without_suggested_area = device_registry.async_update_device( orig_kitchen_light.id, suggested_area=None ) - assert orig_kitchen_light_witout_suggested_area.suggested_area is None - assert orig_kitchen_light_witout_suggested_area == new_kitchen_light + assert orig_kitchen_light_without_suggested_area.area_id == "kitchen" + assert orig_kitchen_light_without_suggested_area == new_kitchen_light async def test_no_unnecessary_changes( @@ -2790,6 +2928,7 @@ async def test_update_remove_config_entries( assert update_events[6].data == { "action": "remove", "device_id": entry3.id, + "device": entry3.dict_repr, } @@ -2969,6 +3108,7 @@ async def test_update_remove_config_subentries( config_entry_3.entry_id: {None}, } + entry_before_remove = entry entry = device_registry.async_update_device( entry_id, remove_config_entry_id=config_entry_3.entry_id, @@ -3066,24 +3206,39 @@ async def test_update_remove_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry_id, + "device": entry_before_remove.dict_repr, } +@pytest.mark.parametrize( + ("initial_area", "device_area_id", "number_of_areas"), + [ + (None, None, 0), + ("Living Room", "living_room", 1), + ], +) async def test_update_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, mock_config_entry: MockConfigEntry, + initial_area: str | None, + device_area_id: str | None, + number_of_areas: int, ) -> None: - """Verify that we can update the suggested area version of a device.""" + """Verify that we can update the suggested area of a device. + + Updating the suggested area of a device should not create a new area, nor should + it change the area_id of the device. + """ update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bla", "123")}, + suggested_area=initial_area, ) - assert not entry.suggested_area - assert entry.area_id is None + assert entry.area_id == device_area_id suggested_area = "Pool" @@ -3092,27 +3247,24 @@ async def test_update_suggested_area( entry.id, suggested_area=suggested_area ) - assert mock_save.call_count == 1 + # Check the device registry was not saved + assert mock_save.call_count == 0 assert updated_entry != entry - assert updated_entry.suggested_area == suggested_area + assert updated_entry.area_id == device_area_id - pool_area = area_registry.async_get_area_by_name("Pool") - assert pool_area is not None - assert updated_entry.area_id == pool_area.id - assert len(area_registry.areas) == 1 + # Check we did not create an area + pool_area = area_registry.async_get_area_by_name(suggested_area) + assert pool_area is None + assert updated_entry.area_id == device_area_id + assert len(area_registry.areas) == number_of_areas await hass.async_block_till_done() - assert len(update_events) == 2 + assert len(update_events) == 1 assert update_events[0].data == { "action": "create", "device_id": entry.id, } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": {"area_id": None, "suggested_area": None}, - } # Do not save or fire the event if the suggested # area does not result in a change of area @@ -3121,10 +3273,10 @@ async def test_update_suggested_area( updated_entry = device_registry.async_update_device( entry.id, suggested_area="Other" ) - assert len(update_events) == 2 + assert len(update_events) == 1 assert mock_save_2.call_count == 0 assert updated_entry != entry - assert updated_entry.suggested_area == "Other" + assert updated_entry.area_id == device_area_id async def test_cleanup_device_registry( @@ -3258,11 +3410,13 @@ async def test_cleanup_entity_registry_change( assert len(mock_call.mock_calls) == 2 +@pytest.mark.parametrize("initial_area", [None, "12345A"]) @pytest.mark.usefixtures("freezer") async def test_restore_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_config_entry_with_subentries: MockConfigEntry, + initial_area: str | None, ) -> None: """Make sure device id is stable.""" entry_id = mock_config_entry_with_subentries.entry_id @@ -3287,9 +3441,9 @@ async def test_restore_device( ) # Apply user customizations - device_registry.async_update_device( + entry = device_registry.async_update_device( entry.id, - area_id="12345A", + area_id=initial_area, disabled_by=dr.DeviceEntryDisabler.USER, labels={"label1", "label2"}, name_by_user="Test Friendly Name", @@ -3332,7 +3486,6 @@ async def test_restore_device( name=None, primary_config_entry=entry_id, serial_number=None, - suggested_area=None, sw_version=None, ) # This will restore the original device, user customizations of @@ -3355,7 +3508,7 @@ async def test_restore_device( via_device="via_device_id_new", ) assert entry3 == dr.DeviceEntry( - area_id="12345A", + area_id=initial_area, config_entries={entry_id}, config_entries_subentries={entry_id: {subentry_id}}, configuration_url="http://config_url_new.bla", @@ -3408,6 +3561,7 @@ async def test_restore_device( assert update_events[2].data == { "action": "remove", "device_id": entry.id, + "device": entry.dict_repr, } assert update_events[3].data == { "action": "create", @@ -3730,6 +3884,7 @@ async def test_restore_shared_device( assert update_events[3].data == { "action": "remove", "device_id": entry.id, + "device": updated_device.dict_repr, } assert update_events[4].data == { "action": "create", @@ -3738,6 +3893,7 @@ async def test_restore_shared_device( assert update_events[5].data == { "action": "remove", "device_id": entry.id, + "device": entry2.dict_repr, } assert update_events[6].data == { "action": "create", @@ -4753,3 +4909,50 @@ async def test_update_device_no_connections_or_identifiers( device_registry.async_update_device( device.id, new_connections=set(), new_identifiers=set() ) + + +async def test_connections_validator() -> None: + """Test checking connections validator.""" + with pytest.raises(ValueError, match="Invalid mac address format"): + dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}) + + +async def test_suggested_area_deprecation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Make sure we do not duplicate entries.""" + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + sw_version="sw-version", + name="name", + manufacturer="manufacturer", + model="model", + suggested_area="Game Room", + ) + + game_room_area = area_registry.async_get_area_by_name("Game Room") + assert game_room_area is not None + assert len(area_registry.areas) == 1 + + assert len(device_registry.devices) == 1 + assert entry.area_id == game_room_area.id + assert entry.suggested_area == "Game Room" + + assert ( + "The deprecated function suggested_area was called. It will be removed in " + "HA Core 2026.9. Use code which ignores suggested_area instead" + ) in caplog.text + + device_registry.async_update_device(entry.id, suggested_area="TV Room") + + assert ( + "Detected code that passes a suggested_area to device_registry.async_update " + "device. This will stop working in Home Assistant 2026.9.0, please report " + "this issue" + ) in caplog.text diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index dde0f209706..2cb2bb38030 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -10,6 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import discovery_flow, json as json_helper from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.util import json as json_util @pytest.fixture @@ -151,6 +152,6 @@ def test_discovery_key_serialize_deserialize(key: str | tuple[str]) -> None: ) serialized = json_helper.json_dumps(discovery_key_1) assert ( - discovery_flow.DiscoveryKey.from_json_dict(json_helper.json_loads(serialized)) + discovery_flow.DiscoveryKey.from_json_dict(json_util.json_loads(serialized)) == discovery_key_1 ) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 706f1a1a806..3064d8d4260 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -32,7 +32,7 @@ from homeassistant.core import ( ReleaseChannel, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -584,10 +584,13 @@ async def test_async_remove_no_platform(hass: HomeAssistant) -> None: ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED ent.async_write_ha_state() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED assert len(hass.states.async_entity_ids()) == 1 await ent.async_remove() assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: @@ -597,10 +600,13 @@ async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.entity_id = "test.test" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) + assert ent._platform_state == entity.EntityPlatformState.ADDED ent.async_on_remove(lambda: result.append(1)) await ent.async_remove() assert len(result) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> None: @@ -647,10 +653,12 @@ async def test_async_remove_twice(hass: HomeAssistant) -> None: await ent.async_remove() assert len(result) == 1 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED await ent.async_remove() assert len(result) == 1 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_set_context(hass: HomeAssistant) -> None: @@ -773,7 +781,8 @@ async def test_warn_slow_write_state( mock_entity = entity.Entity() mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" - mock_entity.platform = MagicMock(platform_name="hue") + mock_entity.platform_data = MagicMock(platform_name="hue") + mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): mock_entity.async_write_ha_state() @@ -800,7 +809,8 @@ async def test_warn_slow_write_state_custom_component( mock_entity = CustomComponentEntity() mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" - mock_entity.platform = MagicMock(platform_name="hue") + mock_entity.platform_data = MagicMock(platform_name="hue") + mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): mock_entity.async_write_ha_state() @@ -1781,9 +1791,12 @@ async def test_reuse_entity_object_after_abort( platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.entity_id = "invalid" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) + assert ent._platform_state == entity.EntityPlatformState.REMOVED assert "Invalid entity ID: invalid" in caplog.text await platform.async_add_entities([ent]) + assert ent._platform_state == entity.EntityPlatformState.REMOVED assert ( "Entity 'invalid' cannot be added a second time to an entity platform" in caplog.text @@ -1800,17 +1813,21 @@ async def test_reuse_entity_object_after_entity_registry_remove( platform = MockEntityPlatform(hass, domain="test", platform_name="test") ent = entity.Entity() ent._attr_unique_id = "5678" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) assert ent.registry_entry is entry assert len(hass.states.async_entity_ids()) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entity_registry.async_remove(entry.entity_id) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED await platform.async_add_entities([ent]) assert "Entity 'test.test_5678' cannot be added a second time" in caplog.text assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_reuse_entity_object_after_entity_registry_disabled( @@ -1823,19 +1840,23 @@ async def test_reuse_entity_object_after_entity_registry_disabled( platform = MockEntityPlatform(hass, domain="test", platform_name="test") ent = entity.Entity() ent._attr_unique_id = "5678" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) assert ent.registry_entry is entry assert len(hass.states.async_entity_ids()) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entity_registry.async_update_entity( entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED await platform.async_add_entities([ent]) assert len(hass.states.async_entity_ids()) == 0 assert "Entity 'test.test_5678' cannot be added a second time" in caplog.text + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_change_entity_id( @@ -1865,9 +1886,11 @@ async def test_change_entity_id( platform = MockEntityPlatform(hass, domain="test") ent = MockEntity() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) assert hass.states.get("test.test").state == STATE_UNKNOWN assert len(ent.added_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entry = entity_registry.async_update_entity( entry.entity_id, new_entity_id="test.test2" @@ -1877,6 +1900,7 @@ async def test_change_entity_id( assert len(result) == 1 assert len(ent.added_calls) == 2 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entity_registry.async_update_entity(entry.entity_id, new_entity_id="test.test3") await hass.async_block_till_done() @@ -1884,6 +1908,7 @@ async def test_change_entity_id( assert len(result) == 2 assert len(ent.added_calls) == 3 assert len(ent.remove_calls) == 2 + assert ent._platform_state == entity.EntityPlatformState.ADDED def test_entity_description_as_dataclass(snapshot: SnapshotAssertion) -> None: @@ -2524,6 +2549,7 @@ async def test_remove_entity_registry( assert len(result) == 1 assert len(ent.added_calls) == 1 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED assert hass.states.get("test.test") is None @@ -2628,6 +2654,7 @@ async def test_async_write_ha_state_thread_safety_always( ent.entity_id = "test.any" ent.hass = hass ent.platform = MockEntityPlatform(hass, domain="test") + ent._platform_state = entity.EntityPlatformState.ADDED ent.async_write_ha_state() assert hass.states.get(ent.entity_id) @@ -2641,3 +2668,231 @@ async def test_async_write_ha_state_thread_safety_always( ): await hass.async_add_executor_job(ent2.async_write_ha_state) assert not hass.states.get(ent2.entity_id) + + +async def test_platform_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test platform state.""" + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + async def async_added_to_hass(self): + # The attempt to write when in state ADDING should be ignored + assert self._platform_state == entity.EntityPlatformState.ADDING + self._attr_state = "added_to_hass" + self.async_write_ha_state() + assert hass.states.get("test.test") is None + + async def async_will_remove_from_hass(self): + # The attempt to write when in state REMOVED should be ignored + assert self._platform_state == entity.EntityPlatformState.REMOVED + assert hass.states.get("test.test").state == "added_to_hass" + self._attr_state = "will_remove_from_hass" + self.async_write_ha_state() + assert hass.states.get("test.test").state == "added_to_hass" + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == "added_to_hass" + assert ent._platform_state == entity.EntityPlatformState.ADDED + + entry = entity_registry.async_remove(entry.entity_id) + await hass.async_block_till_done() + + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert hass.states.get("test.test") is None + + +async def test_platform_state_no_platform(hass: HomeAssistant) -> None: + """Test platform state for entities which are not added by an entity platform.""" + + class MockEntity(entity.Entity): + entity_id = "test.test" + + def async_set_state(self, state: str) -> None: + self._attr_state = state + self.async_write_ha_state() + + ent = MockEntity() + ent.hass = hass + assert hass.states.get("test.test") is None + + # The attempt to write when in state NOT_ADDED should be allowed + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + ent.async_set_state("not_added") + assert hass.states.get("test.test").state == "not_added" + + # The attempt to write when in state ADDING should be allowed + ent._platform_state = entity.EntityPlatformState.ADDING + ent.async_set_state("adding") + assert hass.states.get("test.test").state == "adding" + + # The attempt to write when in state ADDED should be allowed + ent._platform_state = entity.EntityPlatformState.ADDED + ent.async_set_state("added") + assert hass.states.get("test.test").state == "added" + + # The attempt to write when in state REMOVED should be ignored + ent._platform_state = entity.EntityPlatformState.REMOVED + ent.async_set_state("removed") + assert hass.states.get("test.test").state == "added" + + +async def test_platform_state_fail_to_add( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test platform state when raising from async_added_to_hass.""" + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + async def async_added_to_hass(self): + raise ValueError("Failed to add entity") + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test") is None + assert ent._platform_state == entity.EntityPlatformState.ADDING + + entry = entity_registry.async_remove(entry.entity_id) + await hass.async_block_till_done() + + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert hass.states.get("test.test") is None + + +async def test_platform_state_write_from_init( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test platform state when an entity attempts to write from init.""" + + class MockEntity(entity.Entity): + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + # The attempt to write when in state NOT_ADDED is prevented because + # the entity has no entity_id set + self._attr_state = "init" + with pytest.raises(NoEntitySpecifiedError): + self.async_write_ha_state() + assert len(hass.states.async_all()) == 0 + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity(hass) + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.unnamed_device").state == "init" + assert ent._platform_state == entity.EntityPlatformState.ADDED + + assert len(hass.states.async_all()) == 1 + + assert "Platform test_platform does not generate unique IDs." not in caplog.text + assert "Entity id already exists" not in caplog.text + + +async def test_platform_state_write_from_init_entity_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test platform state when an entity attempts to write from init. + + The outcome of this test is a bit illogical, when we no longer allow + entities without platforms, attempts to write when state is NOT_ADDED + will be blocked. + """ + + class MockEntity(entity.Entity): + def __init__(self, hass: HomeAssistant) -> None: + self.entity_id = "test.test" + self.hass = hass + # The attempt to write when in state NOT_ADDED is not prevented because + # the platform is not yet set + assert self._platform_state == entity.EntityPlatformState.NOT_ADDED + self._attr_state = "init" + self.async_write_ha_state() + assert hass.states.get("test.test").state == "init" + + async def async_added_to_hass(self): + raise NotImplementedError("Should not be called") + + async def async_will_remove_from_hass(self): + raise NotImplementedError("Should not be called") + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity(hass) + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == "init" + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert len(hass.states.async_all()) == 1 + + # The early attempt to write is interpreted as a state collision + assert "Platform test_platform does not generate unique IDs." not in caplog.text + assert "Entity id already exists - ignoring: test.test" in caplog.text + + +async def test_platform_state_write_from_init_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test platform state when an entity attempts to write from init. + + The outcome of this test is a bit illogical, when we no longer allow + entities without platforms, attempts to write when state is NOT_ADDED + will be blocked. + """ + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + def __init__(self, hass: HomeAssistant) -> None: + self.entity_id = "test.test" + self.hass = hass + # The attempt to write when in state NOT_ADDED is not prevented because + # the platform is not yet set + assert self._platform_state == entity.EntityPlatformState.NOT_ADDED + self._attr_state = "init" + self.async_write_ha_state() + assert hass.states.get("test.test").state == "init" + + async def async_added_to_hass(self): + raise NotImplementedError("Should not be called") + + async def async_will_remove_from_hass(self): + raise NotImplementedError("Should not be called") + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity(hass) + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == "init" + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert len(hass.states.async_all()) == 1 + + # The early attempt to write is interpreted as a unique ID collision + assert "Platform test_platform does not generate unique IDs." in caplog.text + assert "Entity id already exists - ignoring: test.test" not in caplog.text diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 08510364eba..53331b676fe 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2447,3 +2447,56 @@ async def test_add_entity_unknown_subentry( "Can't add entities to unknown subentry unknown-subentry " "of config entry super-mock-id" ) in caplog.text + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.parametrize( + "deprecated_attribute", + [ + "component_translations", + "platform_translations", + "object_id_component_translations", + "object_id_platform_translations", + "default_language_platform_translations", + ], +) +async def test_deprecated_attributes( + hass: HomeAssistant, + deprecated_attribute: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting the device name based on input info.""" + + platform = MockPlatform() + entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) + + assert getattr(entity_platform, deprecated_attribute) is getattr( + entity_platform.platform_data, deprecated_attribute + ) + assert ( + f"The deprecated function {deprecated_attribute} was called from " + "my_integration. It will be removed in HA Core 2026.8. Use platform_data." + f"{deprecated_attribute} instead, please report it to the author of the " + "'my_integration' custom integration" in caplog.text + ) + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deprecated_async_load_translations( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting the device name based on input info.""" + + platform = MockPlatform() + entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) + + await entity_platform.async_load_translations() + assert ( + "The deprecated function async_load_translations was called from " + "my_integration. It will be removed in HA Core 2026.8. Use platform_data." + "async_load_translations instead, please report it to the author of the " + "'my_integration' custom integration" in caplog.text + ) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 714dfed32e9..e403333d8df 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -16,9 +16,10 @@ from homeassistant.const import ( STATE_UNAVAILABLE, EntityCategory, ) -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.util.dt import utc_from_timestamp, utcnow from tests.common import ( @@ -1640,6 +1641,8 @@ async def test_remove_config_entry_from_device_removes_entities_2( config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry(domain="some_helper") + config_entry_3.add_to_hass(hass) # Create device with two config entries device_registry.async_get_or_create( @@ -1662,8 +1665,18 @@ async def test_remove_config_entry_from_device_removes_entities_2( "5678", device_id=device_entry.id, ) + # Create an entity with a config entry not in the device + entry_2 = entity_registry.async_get_or_create( + "light", + "some_helper", + "5678", + config_entry=config_entry_3, + device_id=device_entry.id, + ) + assert entry_1.entity_id != entry_2.entity_id assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) # Remove the first config entry from the device device_registry.async_update_device( @@ -1672,7 +1685,23 @@ async def test_remove_config_entry_from_device_removes_entities_2( await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) + # Entities which are not tied to the removed config entry should not be removed assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + + # Remove the second config entry from the device (this removes the device) + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry_2.entry_id + ) + await hass.async_block_till_done() + + assert not device_registry.async_get(device_entry.id) + # Entities which are not tied to a config entry in the device should not be removed + assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + # Check the device link is set to None + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None async def test_remove_config_subentry_from_device_removes_entities( @@ -1797,10 +1826,19 @@ async def test_remove_config_subentry_from_device_removes_entities( assert not entity_registry.async_is_registered(entry_3.entity_id) +@pytest.mark.parametrize( + ("subentries_in_device", "subentry_in_entity"), + [ + (["mock-subentry-id-1", "mock-subentry-id-2"], None), + ([None, "mock-subentry-id-2"], "mock-subentry-id-1"), + ], +) async def test_remove_config_subentry_from_device_removes_entities_2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + subentries_in_device: list[str | None], + subentry_in_entity: str | None, ) -> None: """Test that we don't remove entities with no config entry when device is modified.""" config_entry_1 = MockConfigEntry( @@ -1820,28 +1858,31 @@ async def test_remove_config_subentry_from_device_removes_entities_2( title="Mock title", unique_id="test", ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-3", + subentry_type="test", + title="Mock title", + unique_id="test", + ), ], ) config_entry_1.add_to_hass(hass) - # Create device with three config subentries + # Create device with two config subentries device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-2", + config_subentry_id=subentries_in_device[0], connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id=subentries_in_device[1], connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert device_entry.config_entries == {config_entry_1.entry_id} assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, + config_entry_1.entry_id: set(subentries_in_device), } # Create an entity without config entry or subentry @@ -1851,30 +1892,61 @@ async def test_remove_config_subentry_from_device_removes_entities_2( "5678", device_id=device_entry.id, ) + # Create an entity for same config entry but subentry not in device + entry_2 = entity_registry.async_get_or_create( + "light", + "some_helper", + "5678", + config_entry=config_entry_1, + config_subentry_id=subentry_in_entity, + device_id=device_entry.id, + ) + # Create an entity for same config entry but subentry not in device + entry_3 = entity_registry.async_get_or_create( + "light", + "some_helper", + "abcd", + config_entry=config_entry_1, + config_subentry_id="mock-subentry-id-3", + device_id=device_entry.id, + ) + assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3 assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) # Remove the first config subentry from the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, + remove_config_subentry_id=subentries_in_device[0], ) await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) + # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the second config subentry from the device + # Remove the second config subentry from the device, this removes the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1", + remove_config_subentry_id=subentries_in_device[1], ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) + assert not device_registry.async_get(device_entry.id) + # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) + # Check the device link is set to None + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None + assert entity_registry.async_get(entry_3.entity_id).device_id is None async def test_update_device_race( @@ -1914,6 +1986,67 @@ async def test_update_device_race( assert not entity_registry.async_is_registered(entry.entity_id) +async def test_update_device_race_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test race when a device is removed. + + This test simulates the behavior of helpers which are removed when the + source entity is removed. + """ + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Create device + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + # Add entity to the device, from the same config entry + entry_same_config_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + # Add entity to the device, not from the same config entry + entry_no_config_entry = entity_registry.async_get_or_create( + "light", + "helper", + "abcd", + device_id=device_entry.id, + ) + # Add a third entity to the device, from the same config entry + entry_same_config_entry_2 = entity_registry.async_get_or_create( + "sensor", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Add a listener to remove the 2nd entity it when 1st entity is removed + @callback + def on_entity_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + if event.data["action"] == "remove": + entity_registry.async_remove(entry_no_config_entry.entity_id) + + async_track_entity_registry_updated_event( + hass, entry_same_config_entry.entity_id, on_entity_event + ) + + device_registry.async_remove_device(device_entry.id) + await hass.async_block_till_done() + + assert not entity_registry.async_is_registered(entry_same_config_entry.entity_id) + assert not entity_registry.async_is_registered(entry_no_config_entry.entity_id) + assert not entity_registry.async_is_registered(entry_same_config_entry_2.entity_id) + + async def test_disable_device_disables_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 465d1b1778b..32cf3edf010 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4946,6 +4946,35 @@ async def test_async_track_state_report_event(hass: HomeAssistant) -> None: unsub() +async def test_async_track_state_report_change_event(hass: HomeAssistant) -> None: + """Test listen for both state change and state report events.""" + tracker_called: dict[str, list[str]] = {"light.bowl": [], "light.top": []} + + @ha.callback + def on_state_change(event: Event[EventStateChangedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + @ha.callback + def on_state_report(event: Event[EventStateReportedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + async_track_state_change_event(hass, ["light.bowl", "light.top"], on_state_change) + async_track_state_report_event(hass, ["light.bowl", "light.top"], on_state_report) + entity_ids = ["light.bowl", "light.top"] + state_sequence = ["on", "on", "off", "off"] + for state in state_sequence: + for entity_id in entity_ids: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + assert tracker_called == { + "light.bowl": ["on", "on", "off", "off"], + "light.top": ["on", "on", "off", "off"], + } + + async def test_async_track_template_no_hass_deprecated( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 47f1b62feb7..640b2ff011a 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, Mock import pytest from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from tests.common import ( MockConfigEntry, @@ -152,7 +155,7 @@ def mock_helper_integration( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, - source_entity_removed: AsyncMock, + source_entity_removed: AsyncMock | None, ) -> None: """Mock the helper integration.""" @@ -184,6 +187,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -193,6 +197,23 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s return events +def listen_entity_registry_events( + hass: HomeAssistant, +) -> list[er.EventEntityRegistryUpdatedData]: + """Track entity registry actions for an entity.""" + events: list[er.EventEntityRegistryUpdatedData] = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data) + + hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, add_event) + + return events + + +@pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") async def test_async_handle_source_entity_changes_source_entity_removed( @@ -207,6 +228,70 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the helper config entry is removed when the source entity is removed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entitys's config entry from the device, this removes the + # source entity + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None + async_unload_entry.assert_not_called() + async_remove_entry.assert_not_called() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is not removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed_custom_handler( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" @@ -425,3 +510,85 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("source_entity_entry") +async def test_async_remove_helper_config_entry_from_source_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, +) -> None: + """Test removing the helper config entry from the source device.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + # Create a helper entity entry, not connected to the source device + extra_helper_entity_entry = entity_registry.async_get_or_create( + "sensor", + HELPER_DOMAIN, + f"{helper_config_entry.entry_id}_2", + config_entry=helper_config_entry, + original_name="ABC", + ) + assert extra_helper_entity_entry.entity_id != helper_entity_entry.entity_id + + events = listen_entity_registry_events(hass) + + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=helper_config_entry.entry_id, + source_device_id=source_device.id, + ) + + # Check we got the expected events + assert events == [ + { + "action": "update", + "changes": {"device_id": source_device.id}, + "entity_id": helper_entity_entry.entity_id, + }, + { + "action": "update", + "changes": {"device_id": None}, + "entity_id": helper_entity_entry.entity_id, + }, + ] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("source_entity_entry") +async def test_async_remove_helper_config_entry_from_source_device_helper_not_in_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, +) -> None: + """Test removing the helper config entry from the source device.""" + # Create a helper entity entry, not connected to the source device + extra_helper_entity_entry = entity_registry.async_get_or_create( + "sensor", + HELPER_DOMAIN, + f"{helper_config_entry.entry_id}_2", + config_entry=helper_config_entry, + original_name="ABC", + ) + assert extra_helper_entity_entry.entity_id != helper_entity_entry.entity_id + + events = listen_entity_registry_events(hass) + + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=helper_config_entry.entry_id, + source_device_id=source_device.id, + ) + + # Check we got the expected events + assert events == [] diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 94f21da1781..26ee4c675bb 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -13,7 +13,6 @@ from unittest.mock import Mock, patch import pytest from homeassistant.core import Event, HomeAssistant, State -from homeassistant.helpers import json as json_helper from homeassistant.helpers.json import ( ExtendedJSONEncoder, JSONEncoder as DefaultHASSJSONEncoder, @@ -27,14 +26,9 @@ from homeassistant.helpers.json import ( ) from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor -from homeassistant.util.json import ( - JSON_DECODE_EXCEPTIONS, - JSON_ENCODE_EXCEPTIONS, - SerializationError, - load_json, -) +from homeassistant.util.json import SerializationError, load_json -from tests.common import import_and_test_deprecated_constant, json_round_trip +from tests.common import json_round_trip # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} @@ -350,50 +344,3 @@ def test_find_unserializable_data() -> None: BadData(), dump=partial(json.dumps, cls=MockJSONEncoder), ) == {"$(BadData).bla": bad_data} - - -def test_deprecated_json_loads(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated json_loads function. - - It was moved from helpers to util in #88099 - """ - json_helper.json_loads("{}") - assert ( - "json_loads is a deprecated function which will be removed in " - "HA Core 2025.8. Use homeassistant.util.json.json_loads instead" - ) in caplog.text - - -@pytest.mark.parametrize( - ("constant_name", "replacement_name", "replacement"), - [ - ( - "JSON_DECODE_EXCEPTIONS", - "homeassistant.util.json.JSON_DECODE_EXCEPTIONS", - JSON_DECODE_EXCEPTIONS, - ), - ( - "JSON_ENCODE_EXCEPTIONS", - "homeassistant.util.json.JSON_ENCODE_EXCEPTIONS", - JSON_ENCODE_EXCEPTIONS, - ), - ], -) -def test_deprecated_aliases( - caplog: pytest.LogCaptureFixture, - constant_name: str, - replacement_name: str, - replacement: Any, -) -> None: - """Test deprecated JSON_DECODE_EXCEPTIONS and JSON_ENCODE_EXCEPTIONS constants. - - They were moved from helpers to util in #88099 - """ - import_and_test_deprecated_constant( - caplog, - json_helper, - constant_name, - replacement_name, - replacement, - "2025.8", - ) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index b6894505534..9ba93cef4ca 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1139,6 +1139,61 @@ async def test_selector_serializer( "type": "object", "additionalProperties": True, } + assert selector_serializer( + selector.ObjectSelector( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {"min": 30, "max": 100}}, + }, + }, + "multiple": False, + "label_field": "name", + }, + ) + ) == { + "type": "object", + "properties": { + "name": {"type": "string"}, + "percentage": {"type": "number", "minimum": 30, "maximum": 100}, + }, + "required": ["name"], + } + assert selector_serializer( + selector.ObjectSelector( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {"min": 30, "max": 100}}, + }, + }, + "multiple": True, + "label_field": "name", + }, + ) + ) == { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "percentage": { + "type": "number", + "minimum": 30, + "maximum": 100, + }, + }, + "required": ["name"], + }, + } assert selector_serializer( selector.SelectSelector( { @@ -1489,18 +1544,18 @@ This is prompt 2 """ ) assert [(tool.name, tool.description) for tool in instance.tools] == [ - ("api-1.Tool_1", "Description 1"), - ("api-2.Tool_2", "Description 2"), + ("api-1__Tool_1", "Description 1"), + ("api-2__Tool_2", "Description 2"), ] # The test tool returns back the provided arguments so we can verify # the original tool is invoked with the correct tool name and args. result = await instance.async_call_tool( - llm.ToolInput(tool_name="api-1.Tool_1", tool_args={"arg1": "value1"}) + llm.ToolInput(tool_name="api-1__Tool_1", tool_args={"arg1": "value1"}) ) assert result == {"result": {"Tool_1": {"arg1": "value1"}}} result = await instance.async_call_tool( - llm.ToolInput(tool_name="api-2.Tool_2", tool_args={"arg2": "value2"}) + llm.ToolInput(tool_name="api-2__Tool_2", tool_args={"arg2": "value2"}) ) assert result == {"result": {"Tool_2": {"arg2": "value2"}}} diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index e67525253bc..e76faf9ee52 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -591,6 +591,45 @@ async def test_suggested_values( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_description_placeholders( + hass: HomeAssistant, manager: data_entry_flow.FlowManager +) -> None: + """Test description_placeholders handling in SchemaFlowFormStep.""" + manager.hass = hass + + OPTIONS_SCHEMA = vol.Schema( + {vol.Optional("option1", default="a very reasonable default"): str} + ) + + async def _get_description_placeholders( + _: SchemaCommonFlowHandler, + ) -> dict[str, Any]: + return {"option1": "a dynamic string"} + + OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep( + OPTIONS_SCHEMA, + next_step="step_1", + description_placeholders=_get_description_placeholders, + ), + } + + class TestFlow(MockSchemaConfigFlowHandler, domain="test"): + config_flow = {} + options_flow = OPTIONS_FLOW + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + config_entry = MockConfigEntry(data={}, domain="test") + config_entry.add_to_hass(hass) + + # Start flow and check the description_placeholders is populated + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert result["description_placeholders"] == {"option1": "a dynamic string"} + + async def test_options_flow_state(hass: HomeAssistant) -> None: """Test flow_state handling in SchemaFlowFormStep.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 8947ea8099c..7f5255a203b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,7 +88,6 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), - ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, @@ -128,6 +127,7 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", } }, ("abc123",), @@ -140,11 +140,13 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", }, { "integration": "matter", "manufacturer": "other-mock-manuf", "model": "other-mock-model", + "model_id": "other-mock-model_id", }, ] }, @@ -158,6 +160,19 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("device", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + [ + # model_id should be used under the filter key + {"model_id": "mock-model_id"}, + ], +) +def test_device_selector_schema_error(schema) -> None: + """Test device selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"device": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), [ @@ -216,6 +231,11 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ["sensor.abc123", "sensor.ghi789"], ), ), + ( + {"multiple": True, "reorder": True}, + ((["sensor.abc123", "sensor.def456"],)), + (None, "abc123", ["sensor.abc123", None]), + ), ( {"filter": {"domain": "light"}}, ("light.abc123", FAKE_UUID), @@ -290,10 +310,12 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"filter": [{"supported_features": ["light.FooEntityFeature.blah"]}]}, # Unknown feature enum member {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + # supported_features should be used under the filter key + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, ], ) def test_entity_selector_schema_error(schema) -> None: - """Test number selector.""" + """Test entity selector.""" with pytest.raises(vol.Invalid): selector.validate_selector({"entity": schema}) @@ -396,7 +418,13 @@ def test_assist_pipeline_selector_schema( ({"min": -100, "max": 100, "step": 5}, (), ()), ({"min": -20, "max": -10, "mode": "box"}, (), ()), ( - {"min": 0, "max": 100, "unit_of_measurement": "seconds", "mode": "slider"}, + { + "min": 0, + "max": 100, + "unit_of_measurement": "seconds", + "mode": "slider", + "translation_key": "foo", + }, (), (), ), @@ -404,6 +432,7 @@ def test_assist_pipeline_selector_schema( ({"mode": "box"}, (10,), ()), ({"mode": "box", "step": "any"}, (), ()), ({"mode": "slider", "min": 0, "max": 1, "step": "any"}, (), ()), + ({}, (), ()), ], ) def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -411,10 +440,28 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("number", schema, valid_selections, invalid_selections) +def test_number_selector_schema_default_mode() -> None: + """Test number selector default mode set on min/max.""" + assert selector.selector({"number": {"min": 10, "max": 50}}).config == { + "mode": "slider", + "min": 10.0, + "max": 50.0, + "step": 1.0, + } + assert selector.selector({"number": {}}).config == { + "mode": "box", + "step": 1.0, + } + assert selector.selector({"number": {"min": "10"}}).config == { + "mode": "box", + "min": 10.0, + "step": 1.0, + } + + @pytest.mark.parametrize( "schema", [ - {}, # Must have mandatory fields {"mode": "slider"}, # Must have min+max in slider mode ], ) @@ -516,7 +563,17 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections) -> N ( {"entity_id": "sensor.abc"}, ("on", "armed"), - (None, True, 1), + (None, True, 1, ["on"]), + ), + ( + {"entity_id": "sensor.abc", "multiple": True}, + (["on"], ["on", "off"], []), + (None, True, 1, [True], [1], "on"), + ), + ( + {"hide_states": ["unknown", "unavailable"]}, + (), + (), ), ], ) @@ -836,7 +893,16 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> "metadata": {}, }, ), - (None, "abc", {}), + ( + None, + "abc", + {}, + # We require entity_id when accept is not set + { + "media_content_id": "abc", + "media_content_type": "def", + }, + ), ), ( { @@ -853,7 +919,18 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> "metadata": {}, }, ), - (None, "abc", {}), + ( + None, + "abc", + {}, + { + # We do not allow entity_id when accept is set + "entity_id": "sensor.abc", + "media_content_id": "abc", + "media_content_type": "def", + "metadata": {}, + }, + ), ), ], ) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 5d018f5f3ee..8f094536988 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterable from copy import deepcopy +import dataclasses import io from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -986,7 +987,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: "test_domain": { "test_service": { "description": "", - "fields": {"test": {"selector": {"text": None}}}, + "fields": {"test": {"selector": {"text": {}}}}, "name": "", } } @@ -1012,6 +1013,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME advanced_stuff: fields: temperature: @@ -1023,6 +1031,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME """ domain = "test_domain" @@ -1064,7 +1079,21 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + "reorder": False, + }, + }, }, }, }, @@ -1073,7 +1102,21 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + "reorder": False, + }, + }, }, }, "name": "", @@ -2322,3 +2365,80 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: ] await asyncio.gather(*tasks) assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) + + +async def test_deprecated_service_target_selector_class(hass: HomeAssistant) -> None: + """Test that the deprecated ServiceTargetSelector class forwards correctly.""" + call = ServiceCall( + hass, + "test", + "test", + { + "entity_id": ["light.test", "switch.test"], + "area_id": "kitchen", + "device_id": ["device1", "device2"], + "floor_id": "first_floor", + "label_id": ["label1", "label2"], + }, + ) + selector = service.ServiceTargetSelector(call) + + assert selector.entity_ids == {"light.test", "switch.test"} + assert selector.area_ids == {"kitchen"} + assert selector.device_ids == {"device1", "device2"} + assert selector.floor_ids == {"first_floor"} + assert selector.label_ids == {"label1", "label2"} + assert selector.has_any_selector is True + + +async def test_deprecated_selected_entities_class( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the deprecated SelectedEntities class forwards correctly.""" + selected = service.SelectedEntities( + referenced={"entity.test"}, + indirectly_referenced=set(), + referenced_devices=set(), + referenced_areas=set(), + missing_devices={"missing_device"}, + missing_areas={"missing_area"}, + missing_floors={"missing_floor"}, + missing_labels={"missing_label"}, + ) + + missing_entities = {"entity.missing"} + selected.log_missing(missing_entities) + assert ( + "Referenced floors missing_floor, areas missing_area, " + "devices missing_device, entities entity.missing, " + "labels missing_label are missing or not currently available" in caplog.text + ) + + +async def test_deprecated_async_extract_referenced_entity_ids( + hass: HomeAssistant, +) -> None: + """Test that the deprecated async_extract_referenced_entity_ids function forwards correctly.""" + from homeassistant.helpers import target # noqa: PLC0415 + + mock_selected = target.SelectedEntities( + referenced={"entity.test"}, + indirectly_referenced={"entity.indirect"}, + ) + with patch( + "homeassistant.helpers.target.async_extract_referenced_entity_ids", + return_value=mock_selected, + ) as mock_target_func: + call = ServiceCall(hass, "test", "test", {"entity_id": "light.test"}) + result = service.async_extract_referenced_entity_ids( + hass, call, expand_group=False + ) + + # Verify target helper was called with correct parameters + mock_target_func.assert_called_once() + args = mock_target_func.call_args + assert args[0][0] is hass + assert args[0][1].entity_ids == {"light.test"} + assert args[0][2] is False + + assert dataclasses.asdict(result) == dataclasses.asdict(mock_selected) diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py new file mode 100644 index 00000000000..09fb16cbe9a --- /dev/null +++ b/tests/helpers/test_target.py @@ -0,0 +1,748 @@ +"""Test service helpers.""" + +import pytest + +from homeassistant.components.group import Group +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, + ENTITY_MATCH_NONE, + STATE_OFF, + STATE_ON, + EntityCategory, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + label_registry as lr, + target, +) +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + RegistryEntryWithDefaults, + mock_area_registry, + mock_device_registry, + mock_registry, +) + + +async def set_states_and_check_target_events( + hass: HomeAssistant, + events: list[target.TargetStateChangedData], + state: str, + entities_to_set_state: list[str], + entities_to_assert_change: list[str], +) -> None: + """Toggle the state entities and check for events.""" + for entity_id in entities_to_set_state: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + assert len(events) == len(entities_to_assert_change) + entities_seen = set() + for event in events: + state_change_event = event.state_change_event + entities_seen.add(state_change_event.data["entity_id"]) + assert state_change_event.data["new_state"].state == state + assert event.targeted_entity_ids == set(entities_to_assert_change) + assert entities_seen == set(entities_to_assert_change) + events.clear() + + +@pytest.fixture +def registries_mock(hass: HomeAssistant) -> None: + """Mock including floor and area info.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set("light.Kitchen", STATE_OFF) + + area_in_floor = ar.AreaEntry( + id="test-area", + name="Test area", + aliases={}, + floor_id="test-floor", + icon=None, + picture=None, + temperature_entity_id=None, + humidity_entity_id=None, + ) + area_in_floor_a = ar.AreaEntry( + id="area-a", + name="Area A", + aliases={}, + floor_id="floor-a", + icon=None, + picture=None, + temperature_entity_id=None, + humidity_entity_id=None, + ) + area_with_labels = ar.AreaEntry( + id="area-with-labels", + name="Area with labels", + aliases={}, + floor_id=None, + icon=None, + labels={"label_area"}, + picture=None, + temperature_entity_id=None, + humidity_entity_id=None, + ) + mock_area_registry( + hass, + { + area_in_floor.id: area_in_floor, + area_in_floor_a.id: area_in_floor_a, + area_with_labels.id: area_with_labels, + }, + ) + + device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area") + device_no_area = dr.DeviceEntry(id="device-no-area-id") + device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area") + device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"}) + device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"}) + device_has_labels = dr.DeviceEntry( + id="device-has-labels-id", + labels={"label1", "label2"}, + area_id=area_with_labels.id, + ) + + mock_device_registry( + hass, + { + device_in_area.id: device_in_area, + device_no_area.id: device_no_area, + device_diff_area.id: device_diff_area, + device_area_a.id: device_area_a, + device_has_label1.id: device_has_label1, + device_has_label2.id: device_has_label2, + device_has_labels.id: device_has_labels, + }, + ) + + entity_in_own_area = RegistryEntryWithDefaults( + entity_id="light.in_own_area", + unique_id="in-own-area-id", + platform="test", + area_id="own-area", + ) + config_entity_in_own_area = RegistryEntryWithDefaults( + entity_id="light.config_in_own_area", + unique_id="config-in-own-area-id", + platform="test", + area_id="own-area", + entity_category=EntityCategory.CONFIG, + ) + hidden_entity_in_own_area = RegistryEntryWithDefaults( + entity_id="light.hidden_in_own_area", + unique_id="hidden-in-own-area-id", + platform="test", + area_id="own-area", + hidden_by=er.RegistryEntryHider.USER, + ) + entity_in_area = RegistryEntryWithDefaults( + entity_id="light.in_area", + unique_id="in-area-id", + platform="test", + device_id=device_in_area.id, + ) + config_entity_in_area = RegistryEntryWithDefaults( + entity_id="light.config_in_area", + unique_id="config-in-area-id", + platform="test", + device_id=device_in_area.id, + entity_category=EntityCategory.CONFIG, + ) + hidden_entity_in_area = RegistryEntryWithDefaults( + entity_id="light.hidden_in_area", + unique_id="hidden-in-area-id", + platform="test", + device_id=device_in_area.id, + hidden_by=er.RegistryEntryHider.USER, + ) + entity_in_other_area = RegistryEntryWithDefaults( + entity_id="light.in_other_area", + unique_id="in-area-a-id", + platform="test", + device_id=device_in_area.id, + area_id="other-area", + ) + entity_assigned_to_area = RegistryEntryWithDefaults( + entity_id="light.assigned_to_area", + unique_id="assigned-area-id", + platform="test", + device_id=device_in_area.id, + area_id="test-area", + ) + entity_no_area = RegistryEntryWithDefaults( + entity_id="light.no_area", + unique_id="no-area-id", + platform="test", + device_id=device_no_area.id, + ) + config_entity_no_area = RegistryEntryWithDefaults( + entity_id="light.config_no_area", + unique_id="config-no-area-id", + platform="test", + device_id=device_no_area.id, + entity_category=EntityCategory.CONFIG, + ) + hidden_entity_no_area = RegistryEntryWithDefaults( + entity_id="light.hidden_no_area", + unique_id="hidden-no-area-id", + platform="test", + device_id=device_no_area.id, + hidden_by=er.RegistryEntryHider.USER, + ) + entity_diff_area = RegistryEntryWithDefaults( + entity_id="light.diff_area", + unique_id="diff-area-id", + platform="test", + device_id=device_diff_area.id, + ) + entity_in_area_a = RegistryEntryWithDefaults( + entity_id="light.in_area_a", + unique_id="in-area-a-id", + platform="test", + device_id=device_area_a.id, + area_id="area-a", + ) + entity_in_area_b = RegistryEntryWithDefaults( + entity_id="light.in_area_b", + unique_id="in-area-b-id", + platform="test", + device_id=device_area_a.id, + area_id="area-b", + ) + entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.with_my_label", + unique_id="with_my_label", + platform="test", + labels={"my-label"}, + ) + hidden_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.hidden_with_my_label", + unique_id="hidden_with_my_label", + platform="test", + labels={"my-label"}, + hidden_by=er.RegistryEntryHider.USER, + ) + config_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.config_with_my_label", + unique_id="config_with_my_label", + platform="test", + labels={"my-label"}, + entity_category=EntityCategory.CONFIG, + ) + entity_with_label1_from_device = RegistryEntryWithDefaults( + entity_id="light.with_label1_from_device", + unique_id="with_label1_from_device", + platform="test", + device_id=device_has_label1.id, + ) + entity_with_label1_from_device_and_different_area = RegistryEntryWithDefaults( + entity_id="light.with_label1_from_device_diff_area", + unique_id="with_label1_from_device_diff_area", + platform="test", + device_id=device_has_label1.id, + area_id=area_in_floor_a.id, + ) + entity_with_label1_and_label2_from_device = RegistryEntryWithDefaults( + entity_id="light.with_label1_and_label2_from_device", + unique_id="with_label1_and_label2_from_device", + platform="test", + labels={"label1"}, + device_id=device_has_label2.id, + ) + entity_with_labels_from_device = RegistryEntryWithDefaults( + entity_id="light.with_labels_from_device", + unique_id="with_labels_from_device", + platform="test", + device_id=device_has_labels.id, + ) + mock_registry( + hass, + { + entity_in_own_area.entity_id: entity_in_own_area, + config_entity_in_own_area.entity_id: config_entity_in_own_area, + hidden_entity_in_own_area.entity_id: hidden_entity_in_own_area, + entity_in_area.entity_id: entity_in_area, + config_entity_in_area.entity_id: config_entity_in_area, + hidden_entity_in_area.entity_id: hidden_entity_in_area, + entity_in_other_area.entity_id: entity_in_other_area, + entity_assigned_to_area.entity_id: entity_assigned_to_area, + entity_no_area.entity_id: entity_no_area, + config_entity_no_area.entity_id: config_entity_no_area, + hidden_entity_no_area.entity_id: hidden_entity_no_area, + entity_diff_area.entity_id: entity_diff_area, + entity_in_area_a.entity_id: entity_in_area_a, + entity_in_area_b.entity_id: entity_in_area_b, + config_entity_with_my_label.entity_id: config_entity_with_my_label, + entity_with_label1_and_label2_from_device.entity_id: entity_with_label1_and_label2_from_device, + entity_with_label1_from_device.entity_id: entity_with_label1_from_device, + entity_with_label1_from_device_and_different_area.entity_id: entity_with_label1_from_device_and_different_area, + entity_with_labels_from_device.entity_id: entity_with_labels_from_device, + entity_with_my_label.entity_id: entity_with_my_label, + hidden_entity_with_my_label.entity_id: hidden_entity_with_my_label, + }, + ) + + +@pytest.mark.parametrize( + ("selector_config", "expand_group", "expected_selected"), + [ + ( + { + ATTR_ENTITY_ID: ENTITY_MATCH_NONE, + ATTR_AREA_ID: ENTITY_MATCH_NONE, + ATTR_FLOOR_ID: ENTITY_MATCH_NONE, + ATTR_LABEL_ID: ENTITY_MATCH_NONE, + }, + False, + target.SelectedEntities(), + ), + ( + {ATTR_ENTITY_ID: "light.bowl"}, + False, + target.SelectedEntities(referenced={"light.bowl"}), + ), + ( + {ATTR_ENTITY_ID: "group.test"}, + True, + target.SelectedEntities(referenced={"light.ceiling", "light.kitchen"}), + ), + ( + {ATTR_ENTITY_ID: "group.test"}, + False, + target.SelectedEntities(referenced={"group.test"}), + ), + ( + {ATTR_AREA_ID: "own-area"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.in_own_area"}, + referenced_areas={"own-area"}, + missing_areas={"own-area"}, + ), + ), + ( + {ATTR_AREA_ID: "test-area"}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.in_area", + "light.assigned_to_area", + }, + referenced_areas={"test-area"}, + referenced_devices={"device-test-area"}, + ), + ), + ( + {ATTR_AREA_ID: ["test-area", "diff-area"]}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.in_area", + "light.diff_area", + "light.assigned_to_area", + }, + referenced_areas={"test-area", "diff-area"}, + referenced_devices={"device-diff-area", "device-test-area"}, + missing_areas={"diff-area"}, + ), + ), + ( + {ATTR_DEVICE_ID: "device-no-area-id"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.no_area"}, + referenced_devices={"device-no-area-id"}, + ), + ), + ( + {ATTR_DEVICE_ID: "device-area-a-id"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.in_area_a", "light.in_area_b"}, + referenced_devices={"device-area-a-id"}, + ), + ), + ( + {ATTR_FLOOR_ID: "test-floor"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.in_area", "light.assigned_to_area"}, + referenced_devices={"device-test-area"}, + referenced_areas={"test-area"}, + missing_floors={"test-floor"}, + ), + ), + ( + {ATTR_FLOOR_ID: ["test-floor", "floor-a"]}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.in_area", + "light.assigned_to_area", + "light.in_area_a", + "light.with_label1_from_device_diff_area", + }, + referenced_devices={"device-area-a-id", "device-test-area"}, + referenced_areas={"area-a", "test-area"}, + missing_floors={"floor-a", "test-floor"}, + ), + ), + ( + {ATTR_LABEL_ID: "my-label"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.with_my_label"}, + missing_labels={"my-label"}, + ), + ), + ( + {ATTR_LABEL_ID: "label1"}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.with_label1_from_device", + "light.with_label1_from_device_diff_area", + "light.with_labels_from_device", + "light.with_label1_and_label2_from_device", + }, + referenced_devices={"device-has-label1-id", "device-has-labels-id"}, + missing_labels={"label1"}, + ), + ), + ( + {ATTR_LABEL_ID: ["label2"]}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.with_labels_from_device", + "light.with_label1_and_label2_from_device", + }, + referenced_devices={"device-has-label2-id", "device-has-labels-id"}, + missing_labels={"label2"}, + ), + ), + ( + {ATTR_LABEL_ID: ["label_area"]}, + False, + target.SelectedEntities( + indirectly_referenced={"light.with_labels_from_device"}, + referenced_devices={"device-has-labels-id"}, + referenced_areas={"area-with-labels"}, + missing_labels={"label_area"}, + ), + ), + ], +) +@pytest.mark.usefixtures("registries_mock") +async def test_extract_referenced_entity_ids( + hass: HomeAssistant, + selector_config: ConfigType, + expand_group: bool, + expected_selected: target.SelectedEntities, +) -> None: + """Test extract_entity_ids method.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set("light.Kitchen", STATE_OFF) + + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=["light.Ceiling", "light.Kitchen"], + icon=None, + mode=None, + object_id=None, + order=None, + ) + + target_data = target.TargetSelectorData(selector_config) + assert ( + target.async_extract_referenced_entity_ids( + hass, target_data, expand_group=expand_group + ) + == expected_selected + ) + + +async def test_async_track_target_selector_state_change_event_empty_selector( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_track_target_selector_state_change_event with empty selector.""" + + @callback + def state_change_callback(event): + """Handle state change events.""" + + with pytest.raises(HomeAssistantError) as excinfo: + target.async_track_target_selector_state_change_event( + hass, {}, state_change_callback + ) + assert str(excinfo.value) == ( + "Target selector {} does not have any selectors defined" + ) + + +async def test_async_track_target_selector_state_change_event( + hass: HomeAssistant, +) -> None: + """Test async_track_target_selector_state_change_event with multiple targets.""" + events: list[target.TargetStateChangedData] = [] + + @callback + def state_change_callback(event: target.TargetStateChangedData): + """Handle state change events.""" + events.append(event) + + last_state = STATE_OFF + + async def set_states_and_check_events( + entities_to_set_state: list[str], entities_to_assert_change: list[str] + ) -> None: + """Toggle the state entities and check for events.""" + nonlocal last_state + last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF + await set_states_and_check_target_events( + hass, events, last_state, entities_to_set_state, entities_to_assert_change + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device_reg = dr.async_get(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device_1")}, + ) + + untargeted_device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "area_device")}, + ) + + entity_reg = er.async_get(hass) + device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light", + device_id=device_entry.id, + ).entity_id + + untargeted_device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="area_device_light", + device_id=untargeted_device_entry.id, + ).entity_id + + untargeted_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="untargeted_light", + ).entity_id + + targeted_entity = "light.test_light" + + targeted_entities = [targeted_entity, device_entity] + await set_states_and_check_events(targeted_entities, []) + + label = lr.async_get(hass).async_create("Test Label").name + area = ar.async_get(hass).async_create("Test Area").id + floor = fr.async_get(hass).async_create("Test Floor").floor_id + + selector_config = { + ATTR_ENTITY_ID: targeted_entity, + ATTR_DEVICE_ID: device_entry.id, + ATTR_AREA_ID: area, + ATTR_FLOOR_ID: floor, + ATTR_LABEL_ID: label, + } + unsub = target.async_track_target_selector_state_change_event( + hass, selector_config, state_change_callback + ) + + # Test directly targeted entity and device + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Add new entity to the targeted device -> should trigger on state change + device_entity_2 = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light_2", + device_id=device_entry.id, + ).entity_id + + targeted_entities = [targeted_entity, device_entity, device_entity_2] + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Test untargeted entity -> should not trigger + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add label to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, labels={label}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove label from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, labels={}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove area from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted device -> should trigger on state change + device_reg.async_update_device(untargeted_device_entry.id, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], + [*targeted_entities, untargeted_device_entity], + ) + + # Remove area from untargeted device -> should not trigger anymore + device_reg.async_update_device(untargeted_device_entry.id, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], targeted_entities + ) + + # Set the untargeted area on the untargeted entity -> should not trigger + untracked_area = ar.async_get(hass).async_create("Untargeted Area").id + entity_reg.async_update_entity(untargeted_entity, area_id=untracked_area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Set targeted floor on the untargeted area -> should trigger now + ar.async_get(hass).async_update(untracked_area, floor_id=floor) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], + [*targeted_entities, untargeted_entity], + ) + + # Remove untargeted area from targeted floor -> should not trigger anymore + ar.async_get(hass).async_update(untracked_area, floor_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # After unsubscribing, changes should not trigger + unsub() + await set_states_and_check_events(targeted_entities, []) + + +async def test_async_track_target_selector_state_change_event_filter( + hass: HomeAssistant, +) -> None: + """Test async_track_target_selector_state_change_event with entity filter.""" + events: list[target.TargetStateChangedData] = [] + + filtered_entity = "" + + @callback + def entity_filter(entity_ids: set[str]) -> set[str]: + return {entity_id for entity_id in entity_ids if entity_id != filtered_entity} + + @callback + def state_change_callback(event: target.TargetStateChangedData): + """Handle state change events.""" + events.append(event) + + last_state = STATE_OFF + + async def set_states_and_check_events( + entities_to_set_state: list[str], entities_to_assert_change: list[str] + ) -> None: + """Toggle the state entities and check for events.""" + nonlocal last_state + last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF + await set_states_and_check_target_events( + hass, events, last_state, entities_to_set_state, entities_to_assert_change + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + entity_reg = er.async_get(hass) + + label = lr.async_get(hass).async_create("Test Label").name + label_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="label_light", + ).entity_id + entity_reg.async_update_entity(label_entity, labels={label}) + + targeted_entity = "light.test_light" + + targeted_entities = [targeted_entity, label_entity] + await set_states_and_check_events(targeted_entities, []) + + selector_config = { + ATTR_ENTITY_ID: targeted_entity, + ATTR_LABEL_ID: label, + } + unsub = target.async_track_target_selector_state_change_event( + hass, selector_config, state_change_callback, entity_filter + ) + + await set_states_and_check_events( + targeted_entities, [targeted_entity, label_entity] + ) + + filtered_entity = targeted_entity + # Fire an event so that the targeted entities are re-evaluated + hass.bus.async_fire( + er.EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": "light.other", + "changes": {}, + }, + ) + await set_states_and_check_events([targeted_entity, label_entity], [label_entity]) + + filtered_entity = label_entity + # Fire an event so that the targeted entities are re-evaluated + hass.bus.async_fire( + er.EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": "light.other", + "changes": {}, + }, + ) + await set_states_and_check_events( + [targeted_entity, label_entity], [targeted_entity] + ) + + unsub() diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 82b6434cf3f..85a2673f17d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -845,6 +845,23 @@ def test_as_function(hass: HomeAssistant) -> None: ) +def test_as_function_no_arguments(hass: HomeAssistant) -> None: + """Test as_function with no arguments.""" + assert ( + template.Template( + """ + {%- macro macro_get_hello(returns) -%} + {%- do returns("Hello") -%} + {%- endmacro -%} + {%- set get_hello = macro_get_hello | as_function -%} + {{ get_hello() }} + """, + hass, + ).async_render() + == "Hello" + ) + + def test_logarithm(hass: HomeAssistant) -> None: """Test logarithm.""" tests = [ diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index f5a2b549f89..d5621a1ae61 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,10 +1,15 @@ """The tests for the trigger helper.""" +import io from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest +from pytest_unordered import unordered import voluptuous as vol +from homeassistant.components.sun import DOMAIN as DOMAIN_SUN +from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH +from homeassistant.components.tag import DOMAIN as DOMAIN_TAG from homeassistant.core import ( CALLBACK_TYPE, Context, @@ -12,6 +17,8 @@ from homeassistant.core import ( ServiceCall, callback, ) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import trigger from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, @@ -23,9 +30,11 @@ from homeassistant.helpers.trigger import ( async_validate_trigger_config, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util.yaml.loader import parse_yaml -from tests.common import MockModule, mock_integration, mock_platform +from tests.common import MockModule, MockPlatform, mock_integration, mock_platform async def test_bad_trigger_platform(hass: HomeAssistant) -> None: @@ -41,7 +50,7 @@ async def test_trigger_subtype(hass: HomeAssistant) -> None: "homeassistant.helpers.trigger.async_get_integration", return_value=MagicMock(async_get_platform=AsyncMock()), ) as integration_mock: - await _async_get_trigger_platform(hass, {"platform": "test.subtype"}) + await _async_get_trigger_platform(hass, "test.subtype") assert integration_mock.call_args == call(hass, "test") @@ -452,7 +461,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Initialize trigger.""" @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @@ -461,7 +470,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: class MockTrigger1(MockTrigger): """Mock trigger 1.""" - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, @@ -472,7 +481,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: class MockTrigger2(MockTrigger): """Mock trigger 2.""" - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, @@ -484,8 +493,8 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: hass: HomeAssistant, ) -> dict[str, type[Trigger]]: return { - "test": MockTrigger1, - "test.trig_2": MockTrigger2, + "_": MockTrigger1, + "trig_2": MockTrigger2, } mock_integration(hass, MockModule("test")) @@ -519,3 +528,292 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: with pytest.raises(KeyError): await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) + + +@pytest.mark.parametrize( + "sun_trigger_descriptions", + [ + """ + _: + fields: + event: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + offset: + selector: + time: null + """, + """ + .anchor: &anchor + - sunrise + - sunset + _: + fields: + event: + example: sunrise + selector: + select: + options: *anchor + offset: + selector: + time: null + """, + ], +) +async def test_async_get_all_descriptions( + hass: HomeAssistant, sun_trigger_descriptions: str +) -> None: + """Test async_get_all_descriptions.""" + tag_trigger_descriptions = """ + _: + fields: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + elif fname.endswith("tag/triggers.yaml"): + trigger_descriptions = tag_trigger_descriptions + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "homeassistant.helpers.trigger._load_triggers_files", + side_effect=trigger._load_triggers_files, + ) as proxy_load_triggers_files, + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + # Test we only load triggers.yaml for integrations with triggers, + # system_health has no triggers + assert proxy_load_triggers_files.mock_calls[0][1][0] == unordered( + [ + await async_get_integration(hass, DOMAIN_SUN), + ] + ) + + # system_health does not have triggers and should not be in descriptions + assert descriptions == { + "sun": { + "fields": { + "event": { + "example": "sunrise", + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, + }, + "offset": {"selector": {"time": {}}}, + } + } + } + + # Verify the cache returns the same object + assert await trigger.async_get_all_descriptions(hass) is descriptions + + # Load the tag integration and check a new cache object is created + assert await async_setup_component(hass, DOMAIN_TAG, {}) + await hass.async_block_till_done() + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + new_descriptions = await trigger.async_get_all_descriptions(hass) + assert new_descriptions is not descriptions + assert new_descriptions == { + "sun": { + "fields": { + "event": { + "example": "sunrise", + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, + }, + "offset": {"selector": {"time": {}}}, + } + }, + "tag": { + "fields": { + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + "reorder": False, + }, + }, + }, + } + }, + } + + # Verify the cache returns the same object + assert await trigger.async_get_all_descriptions(hass) is new_descriptions + + +@pytest.mark.parametrize( + ("yaml_error", "expected_message"), + [ + ( + FileNotFoundError("Blah"), + "Unable to find triggers.yaml for the sun integration", + ), + ( + HomeAssistantError("Test error"), + "Unable to parse triggers.yaml for the sun integration: Test error", + ), + ], +) +async def test_async_get_all_descriptions_with_yaml_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + yaml_error: Exception, + expected_message: str, +) -> None: + """Test async_get_all_descriptions.""" + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml_dict(fname, secrets=None): + raise yaml_error + + with ( + patch( + "homeassistant.helpers.trigger.load_yaml_dict", + side_effect=_load_yaml_dict, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert expected_message in caplog.text + + +async def test_async_get_all_descriptions_with_bad_description( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_get_all_descriptions.""" + sun_service_descriptions = """ + _: + fields: not_a_dict + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + with io.StringIO(sun_service_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert ( + "Unable to parse triggers.yaml for the sun integration: " + "expected a dictionary for dictionary value @ data['_']['fields']" + ) in caplog.text + + +async def test_invalid_trigger_platform( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid trigger platform.""" + mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True))) + mock_platform(hass, "test.trigger", MockPlatform()) + + await async_setup_component(hass, "test", {}) + + assert "Integration test does not provide trigger support, skipping" in caplog.text + + +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_triggers", return_value=True) +async def test_subscribe_triggers( + mock_has_triggers: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test trigger.async_subscribe_platform_events.""" + sun_trigger_descriptions = """ + _: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + else: + raise FileNotFoundError + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + async def broken_subscriber(_): + """Simulate a broken subscriber.""" + raise Exception("Boom!") # noqa: TRY002 + + trigger_events = [] + + async def good_subscriber(new_triggers: set[str]): + """Simulate a working subscriber.""" + trigger_events.append(new_triggers) + + trigger.async_subscribe_platform_events(hass, broken_subscriber) + trigger.async_subscribe_platform_events(hass, good_subscriber) + + assert await async_setup_component(hass, "sun", {}) + + assert trigger_events == [{"sun"}] + assert "Error while notifying trigger platform listener" in caplog.text diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 8389218054d..fcfdd249d75 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -4,7 +4,10 @@ from typing import Any import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ICON, CONF_NAME, CONF_STATE, @@ -20,6 +23,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, + ManualTriggerSensorEntity, ValueTemplate, ) @@ -288,3 +292,38 @@ async def test_trigger_template_complex(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entity.some_other_key == {"test_key": "test_data"} + + +async def test_manual_trigger_sensor_entity_with_date( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability template isn't used.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_STATE: template.Template("{{ as_datetime(value) }}", hass), + CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, + } + + class TestEntity(ManualTriggerSensorEntity): + """Test entity class.""" + + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return "2025-01-01T00:00:00+00:00" + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + variables = entity._template_variables_with_value("2025-01-01T00:00:00+00:00") + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._set_native_value_with_possible_timestamp(entity.state) + await hass.async_block_till_done() + + assert entity.native_value == async_parse_date_datetime( + "2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class + ) + assert entity.state == "2025-01-01T00:00:00+00:00" + assert entity.device_class == SensorDeviceClass.TIMESTAMP diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 5fd9f9e39fd..57e80927e7e 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import update_coordinator +from homeassistant.helpers import frame, update_coordinator from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -165,8 +165,6 @@ async def test_shutdown_on_entry_unload( ) -> None: """Test shutdown is requested on entry unload.""" entry = MockConfigEntry() - config_entries.current_entry.set(entry) - calls = 0 async def _refresh() -> int: @@ -177,6 +175,7 @@ async def test_shutdown_on_entry_unload( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=entry, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -206,6 +205,7 @@ async def test_shutdown_on_hass_stop( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -843,6 +843,7 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: crd = update_coordinator.TimestampDataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=refresh, update_interval=timedelta(seconds=10), @@ -865,39 +866,155 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: assert len(last_update_success_times) == 1 -async def test_config_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "integration_frame_path", ["homeassistant/components/my_integration"] +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_config_entry( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: """Test behavior of coordinator.entry.""" entry = MockConfigEntry() - # Default without context should be None - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert crd.config_entry is None - # Explicit None is OK crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=None ) assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # Explicit entry is OK + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=entry ) assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry different from ContextVar not recommended, but should work + another_entry = MockConfigEntry() + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=another_entry + ) + assert crd.config_entry is another_entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Default without context should log a warning + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + + # Default with context should log a warning + caplog.clear() + frame._REPORTED_INTEGRATIONS.clear() + config_entries.current_entry.set(entry) + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + assert crd.config_entry is entry + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("hass", "mock_integration_frame") +async def test_config_entry_custom_integration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test behavior of coordinator.entry for custom integrations.""" + entry = MockConfigEntry(domain="custom_integration") + + # Default without context should be None + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + + assert crd.config_entry is None + # Should not log any warnings about ContextVar usage for custom integrations + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 + + # Explicit None is OK + caplog.clear() + + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=None + ) + + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry is OK + caplog.clear() + + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=entry + ) + + assert crd.config_entry is entry + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # set ContextVar config_entries.current_entry.set(entry) # Default with ContextVar should match the ContextVar + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is entry + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # Explicit entry different from ContextVar not recommended, but should work another_entry = MockConfigEntry() + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=another_entry ) + assert crd.config_entry is another_entry + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: @@ -920,7 +1037,7 @@ async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> self._unsub = None coordinator = update_coordinator.DataUpdateCoordinator[int]( - hass, _LOGGER, name="test" + hass, _LOGGER, config_entry=None, name="test" ) subscriber = Subscriber() subscriber.start_listen(coordinator) diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 8ae291ac0b7..4ffbca6124a 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -138,3 +138,24 @@ def decorator_checker_fixture(hass_decorator, linter) -> BaseChecker: type_hint_checker = hass_decorator.HassDecoratorChecker(linter) type_hint_checker.module = "homeassistant.components.pylint_test" return type_hint_checker + + +@pytest.fixture(name="hass_enforce_greek_micro_char", scope="package") +def hass_enforce_greek_micro_checker_fixture() -> ModuleType: + """Fixture to the content for the hass_enforce_greek_micro_char check.""" + return _load_plugin_from_file( + "hass_enforce_greek_micro_char", + "pylint/plugins/hass_enforce_greek_micro_char.py", + ) + + +@pytest.fixture(name="enforce_greek_micro_char_checker") +def enforce_greek_micro_char_checker_fixture( + hass_enforce_greek_micro_char, linter +) -> BaseChecker: + """Fixture to provide a hass_enforce_greek_micro_char checker.""" + enforce_greek_micro_char_checker = ( + hass_enforce_greek_micro_char.HassEnforceGreekMicroCharChecker(linter) + ) + enforce_greek_micro_char_checker.module = "homeassistant.components.pylint_test" + return enforce_greek_micro_char_checker diff --git a/tests/pylint/test_enforce_greek_micro_char.py b/tests/pylint/test_enforce_greek_micro_char.py new file mode 100644 index 00000000000..fe0abd9af5f --- /dev/null +++ b/tests/pylint/test_enforce_greek_micro_char.py @@ -0,0 +1,164 @@ +"""Tests for pylint hass_enforce_greek_micro_char plugin.""" + +from __future__ import annotations + +import astroid +from pylint.checkers import BaseChecker +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + # Test using the correct μ-sign \u03bc with annotation + """ + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³" + """, + id="good_const_with_annotation", + ), + pytest.param( + # Test using the correct μ-sign \u03bc with annotation using unicode encoding + """ + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "\u03bcg/m³" + """, + id="good_unicode_const_with_annotation", + ), + pytest.param( + # Test using the correct μ-sign \u03bc without annotation + """ + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER = "μg/m³" + """, + id="good_const_without_annotation", + ), + pytest.param( + # Test using the correct μ-sign \u03bc in a StrEnum class + """ + class UnitOfElectricPotential(StrEnum): + \"\"\"Electric potential units.\"\"\" + + MICROVOLT = "μV" + MILLIVOLT = "mV" + VOLT = "V" + KILOVOLT = "kV" + MEGAVOLT = "MV" + """, + id="good_str_enum", + ), + pytest.param( + # Test using the correct μ-sign \u03bc in a sensor description dict + """ + SENSOR_DESCRIPTION = { + "radiation_rate": AranetSensorEntityDescription( + key="radiation_rate", + translation_key="radiation_rate", + name="Radiation Dose Rate", + native_unit_of_measurement="μSv/h", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + scale=0.001, + ), + } + OTHER_DICT = { + "value_with_bad_mu_should_pass": "µ" + } + """, + id="good_sensor_description", + ), + ], +) +def test_enforce_greek_micro_char( + linter: UnittestLinter, + enforce_greek_micro_char_checker: BaseChecker, + code: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(enforce_greek_micro_char_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + # Test we can detect the legacy coding of μ \u00b5 + # instead of recommended coding of μ \u03bc" with annotation + """ + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" + """, + id="bad_const_with_annotation", + ), + pytest.param( + # Test we can detect the unicode variant of the legacy coding of μ \u00b5 + # instead of recommended coding of μ \u03bc" with annotation + """ + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "\u00b5g/m³" + """, + id="bad_unicode_const_with_annotation", + ), + pytest.param( + # Test we can detect the legacy coding of μ \u00b5 + # instead of recommended coding of μ \u03bc" without annotation + """ + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER = "µg/m³" + """, + id="bad_const_without_annotation", + ), + pytest.param( + # Test we can detect the legacy coding of μ \u00b5 + # instead of recommended coding of μ \u03bc" in a StrEnum class + """ + class UnitOfElectricPotential(StrEnum): + \"\"\"Electric potential units.\"\"\" + + MICROVOLT = "µV" + MILLIVOLT = "mV" + VOLT = "V" + KILOVOLT = "kV" + MEGAVOLT = "MV" + """, + id="bad_str_enum", + ), + pytest.param( + # Test we can detect the legacy coding of μ \u00b5 + # instead of recommended coding of μ \u03bc" in a sensor description dict + """ + SENSOR_DESCRIPTION = { + "radiation_rate": AranetSensorEntityDescription( + key="radiation_rate", + translation_key="radiation_rate", + name="Radiation Dose Rate", + native_unit_of_measurement="µSv/h", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + scale=0.001, + ), + } + """, + id="bad_sensor_description", + ), + ], +) +def test_enforce_greek_micro_char_assign_bad( + linter: UnittestLinter, + enforce_greek_micro_char_checker: BaseChecker, + code: str, +) -> None: + """Bad assignment test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(enforce_greek_micro_char_checker) + + walker.walk(root_node) + messages = linter.release_messages() + assert len(messages) == 1 + message = next(iter(messages)) + assert message.msg_id == "hass-enforce-greek-micro-char" diff --git a/tests/syrupy.py b/tests/syrupy.py index e028d5839cb..919ba1a6cea 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -173,6 +173,9 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): if serialized["primary_config_entry"] is not None: serialized["primary_config_entry"] = ANY serialized.pop("_cache") + # This can be removed when suggested_area is removed from DeviceEntry + serialized.pop("_suggested_area") + serialized.pop("is_new") return cls._remove_created_and_modified_at(serialized) @classmethod diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 45bb956b7a1..9a62fd421b7 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -15,6 +15,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant import config_entries, data_entry_flow, loader from homeassistant.config_entries import ConfigEntry @@ -4901,6 +4902,7 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -4941,6 +4943,7 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5020,6 +5023,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5072,6 +5076,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -6514,6 +6519,275 @@ async def test_update_subentry_and_abort( assert result["reason"] == "reconfigure_successful" +@pytest.mark.parametrize( + ( + "kwargs", + "expected_title", + "expected_unique_id", + "expected_data", + "raises", + "reload", # True is default + "setup_call_count", + "expected_result", + ), + [ + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + }, + "Updated title", + "5678", + {"vendor": "data2"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + False, + 1, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {}, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "data": {"buyer": "me"}, + }, + "Test", + "1234", + {"buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {"data_updates": {"buyer": "me"}}, + "Test", + "1234", + {"vendor": "data", "buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + "data_updates": {"buyer": "me"}, + }, + "Test", + "1234", + {"vendor": "data"}, + pytest.raises(ValueError), + True, + 1, + {}, + ), + ], + ids=[ + "changed_entry_default", + "unchanged_entry_default", + "unchanged_entry_no_reload", + "no_kwargs", + "replace_data", + "update_data", + "update_and_data_raises", + ], +) +async def test_update_subentry_reload_and_abort( + hass: HomeAssistant, + expected_title: str, + expected_unique_id: str, + expected_data: dict[str, Any], + kwargs: dict[str, Any], + raises: AbstractContextManager, + reload: bool, + setup_call_count: int, + expected_result: dict[str, Any], +) -> None: + """Test updating an entry and reloading.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + subentry = entry.subentries[subentry_id] + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + **kwargs, + reload_even_if_entry_is_unchanged=reload, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("comp", TestFlow), raises: + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + await hass.async_block_till_done() + + subentry = entry.subentries[subentry_id] + assert subentry.title == expected_title + assert subentry.unique_id == expected_unique_id + assert subentry.data == expected_data + assert setup_entry.call_count == setup_call_count + for k, v in expected_result.items(): + assert result[k] == v + + +async def test_update_subentry_reload_with_listener(hass: HomeAssistant) -> None: + """Test updating an entry and reloading fails with update listener.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + entry.add_update_listener(AsyncMock()) + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data={}, + reload_even_if_entry_is_unchanged=True, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("comp", TestFlow), + pytest.raises( + ValueError, match="Cannot update and reload entry with update listeners" + ), + ): + await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None: """Test it's not allowed to create a subentry from a subentry reconfigure flow.""" subentry_id = "blabla" @@ -8003,7 +8277,10 @@ async def test_get_reconfigure_entry( async def test_subentry_get_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test subentry _get_entry and _get_reconfigure_subentry behavior.""" + """Test subentry _get_entry and _get_reconfigure_subentry behavior. + + Also tests related helpers _entry_id, _subentry_type, _reconfigure_subentry_id + """ subentry_id = "mock_subentry_id" entry = MockConfigEntry( data={}, @@ -8039,18 +8316,8 @@ async def test_subentry_get_entry( async def _async_step_confirm(self): """Confirm input.""" - try: - entry = self._get_entry() - except ValueError as err: - reason = str(err) - else: - reason = f"Found entry {entry.title}" - try: - entry_id = self._entry_id - except ValueError: - reason = f"{reason}: -" - else: - reason = f"{reason}: {entry_id}" + reason = f"Found entry {self._get_entry().title},{self._entry_id}: " + reason = f"{reason}subentry_type={self._subentry_type}" try: subentry = self._get_reconfigure_subentry() @@ -8078,9 +8345,9 @@ async def test_subentry_get_entry( # A reconfigure flow finds the config entry and subentry with mock_config_flow("test", TestFlow): result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Found subentry Test: mock_subentry_id" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Found subentry Test: mock_subentry_id" ) # The subentry_id does not exist @@ -8092,9 +8359,9 @@ async def test_subentry_get_entry( "subentry_id": "01JRemoved", }, ) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Subentry not found: 01JRemoved" ) # A user flow finds the config entry but not the subentry @@ -8102,9 +8369,9 @@ async def test_subentry_get_entry( result = await manager.subentries.async_init( (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} ) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Source is user, expected reconfigure: -" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Source is user, expected reconfigure: -" ) @@ -8652,6 +8919,95 @@ async def test_options_flow_config_entry( assert result["reason"] == "abort" +@pytest.mark.parametrize( + ( + "option_flow_base_class", + "number_of_update_listeners", + "expected_configure_result", + "expected_number_of_unloads", + ), + [ + (config_entries.OptionsFlow, 0, does_not_raise(), 0), + (config_entries.OptionsFlowWithReload, 0, does_not_raise(), 1), + (config_entries.OptionsFlow, 1, does_not_raise(), 0), + ( + config_entries.OptionsFlowWithReload, + 1, + pytest.raises( + ValueError, + match="Config entry update listeners should not be used with OptionsFlowWithReload", + ), + 0, + ), + ], +) +async def test_options_flow_automatic_reload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + option_flow_base_class: type[config_entries.OptionsFlow], + number_of_update_listeners: int, + expected_configure_result: AbstractContextManager, + expected_number_of_unloads: int, +) -> None: + """Test options flow with automatic reload when updated.""" + original_entry = MockConfigEntry( + domain="test", title="Test", data={}, options={"test": "first"} + ) + original_entry.add_to_hass(hass) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Mock setup entry.""" + for _ in range(number_of_update_listeners): + entry.add_update_listener(Mock()) + return True + + unload_entry_mock = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry, + async_unload_entry=unload_entry_mock, + ), + ) + mock_platform(hass, "test.config_flow", None) + + await hass.config_entries.async_setup(original_entry.entry_id) + assert original_entry.state is config_entries.ConfigEntryState.LOADED + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(option_flow_base_class): + """Test flow.""" + + async def async_step_init(self, user_input=None): + """Test user step.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + return self.async_show_form( + step_id="init", data_schema=vol.Schema({"test": str}) + ) + + return _OptionsFlow() + + with mock_config_flow("test", TestFlow): + result = await hass.config_entries.options.async_init(original_entry.entry_id) + with expected_configure_result: + await hass.config_entries.options.async_configure( + result["flow_id"], {"test": "updated"} + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(unload_entry_mock.mock_calls) == expected_number_of_unloads + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_options_flow_deprecated_config_entry_setter( @@ -8823,7 +9179,7 @@ async def test_create_entry_existing_unique_id( log_text = ( f"Detected that integration '{domain}' creates a config entry " - "when another entry with the same unique ID exists. Please " - "create a bug report at https:" + "when another entry with the same unique ID exists. This will stop " + "working in Home Assistant 2026.3, please create a bug report at https:" ) assert (log_text in caplog.text) == expected_log diff --git a/tests/test_const.py b/tests/test_const.py index a039545a004..3398a571f6f 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -118,7 +118,7 @@ def test_deprecated_unit_of_conductivity_alias() -> None: """Test UnitOfConductivity deprecation.""" # Test the deprecated members are aliases - assert set(const.UnitOfConductivity) == {"S/cm", "µS/cm", "mS/cm"} + assert set(const.UnitOfConductivity) == {"S/cm", "μS/cm", "mS/cm"} def test_deprecated_unit_of_conductivity_members( @@ -166,8 +166,8 @@ def test_deprecated_unit_of_conductivity_members( def deprecation_message(member: str, replacement: str) -> str: return ( - f"UnitOfConductivity.{member} was used from hue, this is a deprecated enum " - "member which will be removed in HA Core 2025.11.0. Use UnitOfConductivity." + f"The deprecated enum member UnitOfConductivity.{member} was used from hue. " + "It will be removed in HA Core 2025.11.0. Use UnitOfConductivity." f"{replacement} instead, please report it to the author of the 'hue' custom" " integration" ) diff --git a/tests/test_core.py b/tests/test_core.py index d4b5933aebe..0daaafe74cf 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1847,7 +1847,7 @@ async def test_services_call_return_response_requires_blocking( return_response=True, ) assert str(exc.value) == ( - "A non blocking action call with argument blocking=False " + "A non-blocking action call with argument blocking=False " "can't be used together with argument return_response=True" ) diff --git a/tests/test_core_config.py b/tests/test_core_config.py index bbf7027e7ef..b20503121fc 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -38,7 +38,7 @@ from homeassistant.core_config import ( async_process_ha_core_config, ) from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityPlatformState from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, @@ -222,6 +222,7 @@ async def _compute_state(hass: HomeAssistant, config: dict[str, Any]) -> State | entity.entity_id = "test.test" entity.hass = hass entity.platform = MockEntityPlatform(hass) + entity._platform_state = EntityPlatformState.ADDED entity.schedule_update_ha_state() await hass.async_block_till_done() diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index a5908f0feab..f0912188b9e 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -135,6 +135,19 @@ async def test_show_form(manager: MockFlowManager) -> None: async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) -> None: """Test that we can show a form with suggested values.""" + + def compare_schemas(schema: vol.Schema, expected_schema: vol.Schema) -> None: + """Compare two schemas.""" + assert schema.schema is not expected_schema.schema + + assert list(schema.schema) == list(expected_schema.schema) + + for key, validator in schema.schema.items(): + if isinstance(validator, data_entry_flow.section): + assert validator.schema == expected_schema.schema[key].schema + continue + assert validator == expected_schema.schema[key] + schema = vol.Schema( { vol.Required("username"): str, @@ -155,20 +168,25 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) async def async_step_init(self, user_input=None): data_schema = self.add_suggested_values_to_schema( schema, - { - "username": "doej", - "password": "verySecret1", - "section_1": {"full_name": "John Doe"}, - }, + user_input, ) return self.async_show_form( step_id="init", data_schema=data_schema, ) - form = await manager.async_init("test") + form = await manager.async_init( + "test", + data={ + "username": "doej", + "password": "verySecret1", + "section_1": {"full_name": "John Doe"}, + }, + ) assert form["type"] == data_entry_flow.FlowResultType.FORM - assert form["data_schema"].schema == schema.schema + assert form["data_schema"].schema is not schema.schema + assert form["data_schema"].schema != schema.schema + compare_schemas(form["data_schema"], schema) markers = list(form["data_schema"].schema) assert len(markers) == 3 assert markers[0] == "username" @@ -178,15 +196,41 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) assert markers[2] == "section_1" section_validator = form["data_schema"].schema["section_1"] assert isinstance(section_validator, data_entry_flow.section) - # The section class was not replaced - assert section_validator is schema.schema["section_1"] - # The section schema was not replaced - assert section_validator.schema is schema.schema["section_1"].schema + # The section instance was copied + assert section_validator is not schema.schema["section_1"] + # The section schema instance was copied + assert section_validator.schema is not schema.schema["section_1"].schema + assert section_validator.schema == schema.schema["section_1"].schema section_markers = list(section_validator.schema.schema) assert len(section_markers) == 1 assert section_markers[0] == "full_name" assert section_markers[0].description == {"suggested_value": "John Doe"} + # Test again without suggested values to make sure we're not mutating the schema + form = await manager.async_init( + "test", + ) + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["data_schema"].schema is not schema.schema + assert form["data_schema"].schema == schema.schema + markers = list(form["data_schema"].schema) + assert len(markers) == 3 + assert markers[0] == "username" + assert markers[0].description is None + assert markers[1] == "password" + assert markers[1].description is None + assert markers[2] == "section_1" + section_validator = form["data_schema"].schema["section_1"] + assert isinstance(section_validator, data_entry_flow.section) + # The section class is not replaced if there is no suggested value for the section + assert section_validator is schema.schema["section_1"] + # The section schema is not replaced if there is no suggested value for the section + assert section_validator.schema is schema.schema["section_1"].schema + section_markers = list(section_validator.schema.schema) + assert len(section_markers) == 1 + assert section_markers[0] == "full_name" + assert section_markers[0].description is None + async def test_abort_removes_instance(manager: MockFlowManager) -> None: """Test that abort removes the flow from progress.""" @@ -1185,7 +1229,13 @@ def test_section_in_serializer() -> None: ) == { "expanded": True, "schema": [ - {"default": False, "name": "option_1", "optional": True, "type": "boolean"}, + { + "default": False, + "name": "option_1", + "optional": True, + "required": False, + "type": "boolean", + }, {"name": "option_2", "required": True, "type": "integer"}, ], "type": "expandable", diff --git a/tests/test_loader.py b/tests/test_loader.py index 2d5ad76aa8a..c67b520c7dc 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1143,10 +1143,10 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE), ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), - # Integration domain is not currently deduced from module - (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), # Loaded custom integration with known issue tracker + (None, "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", None, CUSTOM_ISSUE_TRACKER), # Loaded custom integration without known issue tracker @@ -1155,6 +1155,7 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" ("bla_custom_no_tracker", None, None), ("hue", "custom_components.bla.sensor", None), # Unloaded custom integration with known issue tracker + (None, "custom_components.bla_custom_not_loaded.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER), # Unloaded custom integration without known issue tracker ("bla_custom_not_loaded_no_tracker", None, None), @@ -1218,8 +1219,7 @@ async def test_async_get_issue_tracker( ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE), ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), - # Integration domain is not currently deduced from module - (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), # Custom integration with known issue tracker - can't find it without hass ("bla_custom", "custom_components.bla_custom.sensor", None), diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index eea3f4e88b4..c3a8be77b77 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -156,6 +156,9 @@ class AiohttpClientMocker: for response in self._mocks: if response.match_request(method, url, params): + # If auth is provided, try to encode it to trigger any encoding errors + if auth is not None: + auth.encode() self.mock_calls.append((method, url, data, headers)) if response.side_effect: response = await response.side_effect(method, url, data) @@ -191,7 +194,6 @@ class AiohttpClientMockResponse: if response is None: response = b"" - self.charset = "utf-8" self.method = method self._url = url self.status = status @@ -261,16 +263,32 @@ class AiohttpClientMockResponse: """Return content.""" return mock_stream(self.response) + @property + def charset(self): + """Return charset from Content-Type header.""" + if (content_type := self._headers.get("content-type")) is None: + return None + content_type = content_type.lower() + if "charset=" in content_type: + return content_type.split("charset=")[1].split(";")[0].strip() + return None + async def read(self): """Return mock response.""" return self.response - async def text(self, encoding="utf-8", errors="strict"): + async def text(self, encoding=None, errors="strict") -> str: """Return mock response as a string.""" + # Match real aiohttp behavior: encoding=None means auto-detect + if encoding is None: + encoding = self.charset or "utf-8" return self.response.decode(encoding, errors=errors) - async def json(self, encoding="utf-8", content_type=None, loads=json_loads): + async def json(self, encoding=None, content_type=None, loads=json_loads) -> Any: """Return mock response as a json.""" + # Match real aiohttp behavior: encoding=None means auto-detect + if encoding is None: + encoding = self.charset or "utf-8" return loads(self.response.decode(encoding)) def release(self): diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 3f288962009..c357f5cf39c 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -121,8 +121,8 @@ def test_timestamp_to_utc(caplog: pytest.LogCaptureFixture) -> None: utc_now = dt_util.utcnow() assert dt_util.utc_to_timestamp(utc_now) == utc_now.timestamp() assert ( - "utc_to_timestamp is a deprecated function which will be removed " - "in HA Core 2026.1. Use datetime.timestamp instead" in caplog.text + "The deprecated function utc_to_timestamp was called. It will be " + "removed in HA Core 2026.1. Use datetime.timestamp instead" in caplog.text ) diff --git a/tests/util/test_location.py b/tests/util/test_location.py index ecb54eeeaa9..61d879f3827 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location as location_util -from tests.common import load_fixture +from tests.common import async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker # Paris @@ -77,10 +77,14 @@ def test_get_miles() -> None: async def test_detect_location_info_whoami( - aioclient_mock: AiohttpClientMocker, session: aiohttp.ClientSession + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + session: aiohttp.ClientSession, ) -> None: """Test detect location info using services.home-assistant.io/whoami.""" - aioclient_mock.get(location_util.WHOAMI_URL, text=load_fixture("whoami.json")) + aioclient_mock.get( + location_util.WHOAMI_URL, text=await async_load_fixture(hass, "whoami.json") + ) with patch("homeassistant.util.location.HA_VERSION", "1.0"): info = await location_util.async_detect_location_info(session, _test_real=True) @@ -101,10 +105,14 @@ async def test_detect_location_info_whoami( async def test_dev_url( - aioclient_mock: AiohttpClientMocker, session: aiohttp.ClientSession + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + session: aiohttp.ClientSession, ) -> None: """Test usage of dev URL.""" - aioclient_mock.get(location_util.WHOAMI_URL_DEV, text=load_fixture("whoami.json")) + aioclient_mock.get( + location_util.WHOAMI_URL_DEV, text=await async_load_fixture(hass, "whoami.json") + ) with patch("homeassistant.util.location.HA_VERSION", "1.0.dev0"): info = await location_util.async_detect_location_info(session, _test_real=True) diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index ba473ee0c58..406952881bc 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -2,6 +2,7 @@ import asyncio from functools import partial +import inspect import logging import queue from unittest.mock import patch @@ -102,7 +103,7 @@ def test_catch_log_exception() -> None: async def async_meth(): pass - assert asyncio.iscoroutinefunction( + assert inspect.iscoroutinefunction( logging_util.catch_log_exception(partial(async_meth), lambda: None) ) @@ -120,7 +121,7 @@ def test_catch_log_exception() -> None: wrapped = logging_util.catch_log_exception(partial(sync_meth), lambda: None) assert not is_callback(wrapped) - assert not asyncio.iscoroutinefunction(wrapped) + assert not inspect.iscoroutinefunction(wrapped) @pytest.mark.no_fail_on_log_exception diff --git a/tests/util/test_resource.py b/tests/util/test_resource.py new file mode 100644 index 00000000000..a32ceb1062c --- /dev/null +++ b/tests/util/test_resource.py @@ -0,0 +1,153 @@ +"""Test the resource utility module.""" + +import os +import resource +from unittest.mock import call, patch + +import pytest + +from homeassistant.util.resource import ( + DEFAULT_SOFT_FILE_LIMIT, + set_open_file_descriptor_limit, +) + + +@pytest.mark.parametrize( + ("original_soft", "expected_calls", "should_log_already_sufficient"), + [ + ( + 1024, + [call(resource.RLIMIT_NOFILE, (DEFAULT_SOFT_FILE_LIMIT, 524288))], + False, + ), + ( + DEFAULT_SOFT_FILE_LIMIT - 1, + [call(resource.RLIMIT_NOFILE, (DEFAULT_SOFT_FILE_LIMIT, 524288))], + False, + ), + (DEFAULT_SOFT_FILE_LIMIT, [], True), + (DEFAULT_SOFT_FILE_LIMIT + 1, [], True), + ], +) +def test_set_open_file_descriptor_limit_default( + caplog: pytest.LogCaptureFixture, + original_soft: int, + expected_calls: list, + should_log_already_sufficient: bool, +) -> None: + """Test setting file limit with default value.""" + original_hard = 524288 + with ( + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(original_soft, original_hard), + ), + patch("homeassistant.util.resource.resource.setrlimit") as mock_setrlimit, + ): + set_open_file_descriptor_limit() + + assert mock_setrlimit.call_args_list == expected_calls + assert ( + f"Current soft limit ({original_soft}) is already" in caplog.text + ) is should_log_already_sufficient + + +@pytest.mark.parametrize( + ( + "original_soft", + "custom_limit", + "expected_calls", + "should_log_already_sufficient", + ), + [ + (1499, 1500, [call(resource.RLIMIT_NOFILE, (1500, 524288))], False), + (1500, 1500, [], True), + (1501, 1500, [], True), + ], +) +def test_set_open_file_descriptor_limit_environment_variable( + caplog: pytest.LogCaptureFixture, + original_soft: int, + custom_limit: int, + expected_calls: list, + should_log_already_sufficient: bool, +) -> None: + """Test setting file limit from environment variable.""" + original_hard = 524288 + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": str(custom_limit)}), + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(original_soft, original_hard), + ), + patch("homeassistant.util.resource.resource.setrlimit") as mock_setrlimit, + ): + set_open_file_descriptor_limit() + + assert mock_setrlimit.call_args_list == expected_calls + assert ( + f"Current soft limit ({original_soft}) is already" in caplog.text + ) is should_log_already_sufficient + + +def test_set_open_file_descriptor_limit_exceeds_hard_limit( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting file limit that exceeds hard limit.""" + original_soft, original_hard = (1024, 524288) + excessive_limit = original_hard + 1 + + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": str(excessive_limit)}), + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(original_soft, original_hard), + ), + patch("homeassistant.util.resource.resource.setrlimit") as mock_setrlimit, + ): + set_open_file_descriptor_limit() + + mock_setrlimit.assert_called_once_with( + resource.RLIMIT_NOFILE, (original_hard, original_hard) + ) + assert ( + f"Requested soft limit ({excessive_limit}) exceeds hard limit ({original_hard})" + in caplog.text + ) + + +def test_set_open_file_descriptor_limit_os_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling OSError when setting file limit.""" + with ( + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(1024, 524288), + ), + patch( + "homeassistant.util.resource.resource.setrlimit", + side_effect=OSError("Permission denied"), + ), + ): + set_open_file_descriptor_limit() + + assert "Failed to set file descriptor limit" in caplog.text + assert "Permission denied" in caplog.text + + +def test_set_open_file_descriptor_limit_value_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling ValueError when setting file limit.""" + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": "invalid_value"}), + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(1024, 524288), + ), + ): + set_open_file_descriptor_limit() + + assert "Invalid file descriptor limit value" in caplog.text + assert "'invalid_value'" in caplog.text diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 7d0eb7226a0..08fb7cce067 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -8,11 +8,13 @@ from itertools import chain import pytest from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -27,6 +29,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfReactiveEnergy, + UnitOfReactivePower, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -37,6 +40,7 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -54,6 +58,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -82,9 +87,11 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { EnergyConverter, InformationConverter, MassConverter, + ApparentPowerConverter, PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -96,6 +103,11 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + ApparentPowerConverter: ( + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + 1000, + ), AreaConverter: (UnitOfArea.SQUARE_KILOMETERS, UnitOfArea.SQUARE_METERS, 0.000001), BloodGlucoseConcentrationConverter: ( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, @@ -144,6 +156,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, 1000, ), + ReactivePowerConverter: ( + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + 1000, + ), SpeedConverter: ( UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, @@ -167,6 +184,14 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + ApparentPowerConverter: [ + ( + 10, + UnitOfApparentPower.MILLIVOLT_AMPERE, + 0.01, + UnitOfApparentPower.VOLT_AMPERE, + ), + ], AreaConverter: [ # Square Meters to other units (5, UnitOfArea.SQUARE_METERS, 50000, UnitOfArea.SQUARE_CENTIMETERS), @@ -665,6 +690,32 @@ _CONVERTED_VALUE: dict[ UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, ), ], + ReactivePowerConverter: [ + ( + 10, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + 10000, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + 0.01, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + 0.01, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + 0.00001, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + ), + ], SpeedConverter: [ # 5 km/h / 1.609 km/mi = 3.10686 mi/h (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.106856, UnitOfSpeed.MILES_PER_HOUR), @@ -762,6 +813,13 @@ _CONVERTED_VALUE: dict[ 2000, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), + # 3 g/m³ = 3000 mg/m³ + ( + 3, + CONCENTRATION_GRAMS_PER_CUBIC_METER, + 3000, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + ), ], VolumeConverter: [ (5, UnitOfVolume.LITERS, 1.32086, UnitOfVolume.GALLONS),